Account
Integrations
Connecting a Google account and mapping a property are two separate steps with two separate scopes, and why that distinction matters.
Connecting and mapping are different things
Integrations is where you connect a Google account and then choose which Search Console and Analytics properties feed which of your projects. Those are two separate steps with two separate scopes, and the difference between them is the common reason a freshly connected account can still leave a project's Performance screen empty, in the cases where the second step does not happen on its own.
Connecting stores one Google account's refresh token against your own user
id, not against any project: the lookup behind every use of that connection
finds it by matching your user id alone, with no project in the query at
all, so the connection you make once covers every project you own, current
and future (api/services/integrations/google/oauth.py#get_active_integration,
#upsert_integration). Starting the OAuth handshake, for either product,
checks nothing but who you are signed in as
(api/routers/integrations.py#oauth_start, #ga4_oauth_start).
Mapping is the separate step underneath it, and it is scoped to one project
at a time. A Search Console property or an Analytics property Google hands
back after you connect is tied to a project by setting that property's own
project field to that project's id, one property row, one project
assignment
(api/routers/integrations.py#map_property_to_project,
#map_ga4_property_to_project).
Some of that mapping happens for you, at connect time, with nothing to
click. Completing the handshake queues a discovery pass for whichever of the
two products the returned scopes cover
(api/routers/integrations.py#oauth_complete). That pass reconciles the
properties Google reports and then, for each property that has no project
yet, compares the hostnames it can read for that property against the
domains of the projects you can write to: the ones you created, plus any
project you hold an active owner or editor membership on
(api/services/integrations/google/automap_common.py#load_writable_projects).
A property is mapped only when one of those hostnames is exactly equal to
one such project's domain, both sides first lowercased and stripped of any
scheme, port, path and leading www., and only when exactly one project
matches; two matching projects is an ambiguity it declines rather than
guesses at
(api/services/integrations/google/automap_common.py#match_property_to_project).
Where each side's hostname comes from differs by product, and so does one
extra condition: Search Console reads the property's own URL, and will not
auto-map a project that already has a property attached to this connection,
nor act at all when two properties match the same project; Analytics reads
the hostnames of the property's web data streams, and leaves a property
unmapped when it cannot read them
(api/services/integrations/google/gsc_automap.py#sync_and_automap_gsc,
api/services/integrations/google/ga4_automap.py#sync_and_automap). So in
the ordinary case, one project whose domain is the property's host and no
mapping on it yet, connecting is the only step there is.
Mapping by hand is what the screen is for when that does not happen: the
hostnames differ, as a subdomain-only property does, the match is
ambiguous, the automatic pass was never queued, or you simply want a
different property on that project. Doing it by hand requires write access
to the project you are mapping into: both map handlers run the same
ownership and role check every project-scoped write in the product uses, so
a viewer cannot map a property to a project
(api/routers/integrations.py#map_property_to_project,
#map_ga4_property_to_project). The screen reflects that check rather than
letting the request go and fail: the "Map to a project" dropdown for each
property lists every project you can open, but a project you can only read
is shown marked "(view only)" and cannot be selected
(frontend/src/routes/(app)/settings/integrations/google/+page.svelte#canMapTo).
Unmapping asks the same question. Both unmap handlers first check that the
property belongs to your own Google connection, and then, when that
property currently has a project attached, run the same pair of checks the
map handlers do against that project
(api/routers/integrations.py#unmap_property, #unmap_ga4_property,
api/dependencies.py#get_project_for_user,
api/dependencies.py#require_write_access). So a viewer cannot detach a
property, and neither can someone who has since been taken off the project
they mapped it into, and on screen the Unmap button for a mapped property is
disabled under that same canMapTo check, read against the project the
property is currently attached to
(frontend/src/routes/(app)/settings/integrations/google/+page.svelte#canMapTo).
A property attached to no project reaches neither check, because there is
no project to check against, and detaching it changes nothing in any case.
This is why an account-wide connection and a project with no data are not a contradiction: the account can be genuinely connected while this particular project simply has no property mapped to it yet. See Troubleshooting for what each of those states looks like on screen, and what to do about it.
Search Console and Analytics behave differently, but not in every direction
Mapping does not work the same way for the two products, and the difference sits in only one of the two directions a mapping can be read.
From a project looking down at its properties: a project may hold at most
one Search Console property. Before completing a new mapping, the handler
looks for any other property already holding the target project and clears
that property's mapping first, an atomic swap that keeps a project pinned
to one Search Console property no matter how many times you remap it
(api/routers/integrations.py#map_property_to_project). Analytics carries
no such rule: a project can hold as many mapped Analytics properties as you
choose to give it, because the mapping handler sets the property's project
field directly with no check for an existing mapping on that project at all
(api/routers/integrations.py#map_ga4_property_to_project). The settings
screen states this difference in its own copy, and the flag in the Search
Console picker runs the other way round from what you might expect: what is
marked is a project that already holds a property, so that choosing it
tells you this mapping will move that project's existing one
(frontend/src/routes/(app)/settings/integrations/google/+page.svelte#takenProjectIds).
From a property looking up at its project, the two products agree, and this
is the direction worth checking before assuming the asymmetry runs both
ways. Both the Search Console swap above and the Analytics mapping handler
write a single project id onto the property being mapped, replacing
whatever was there before rather than adding to a list
(api/routers/integrations.py#map_property_to_project,
#map_ga4_property_to_project), so a single property can be attached to
only one project at a time in either system. Nothing about Analytics'
looser rule above lets one Analytics property serve two projects at once;
it only lets one project hold more than one property.
The scopes you are granting
Connecting asks Google for exactly two possible scopes, named as constants
in the code you can check directly, and both are read-only.
webmasters.readonly is the Search Console scope, and analytics.readonly
is the Analytics one; neither string carries a write, edit, or manage
permission, and nothing else in the OAuth flow ever requests a broader scope
than these two
(api/services/integrations/google/oauth.py#GSC_SCOPE, #GA4_SCOPE,
#build_authorization_url). Which of the two you are asked to grant depends
on which Connect button you use: the Search Console button requests only
its own scope, while the Analytics button requests its own scope plus
Search Console's too if you already granted that one, so one consent screen
can cover both going forward
(api/routers/integrations.py#oauth_start, #ga4_oauth_start). Every
authorization request also sets Google's offline access and forces the
consent screen again even for a returning user, specifically so a refresh
token comes back on every connect rather than only the first time
(api/services/integrations/google/oauth.py#build_authorization_url).
One control on this same screen reaches past the connection itself: naming
the Analytics events that count as a conversion for a project. Changing that
list requires the same write access mapping does
(api/routers/integrations.py#update_conversion_events), and what naming an
event actually changes about the numbers elsewhere in the product is
AI traffic's to explain, not this
page's.
Disconnecting, and reconnecting later
Disconnecting asks Google to revoke the stored refresh token, a best-effort
call that is swallowed if it fails, and then marks the connection revoked by
stamping a timestamp on it. It does not delete the row that holds the
connection: the code sets revoked_at and commits, nothing more
(api/routers/integrations.py#disconnect,
api/services/integrations/google/oauth.py#revoke_refresh_token). Every
property mapping you had is left exactly as it was: disconnecting never
touches a property's project_id, and nothing keyed off revoked_at
deletes a previously imported day of Search Console or Analytics data
either. What actually stops is new data: the function every import route
calls to get a working access token checks for a live, unrevoked
connection first and returns nothing once one has been disconnected
(api/services/integrations/google/oauth.py#get_active_integration,
#get_access_token_for_user).
Reconnecting later reuses that same row rather than creating a new one:
the upsert that runs at the end of a successful OAuth handshake finds the
existing connection by your user id, overwrites its token and scopes, and
clears revoked_at
(api/services/integrations/google/oauth.py#upsert_integration). Because
disconnecting never touched your mappings, every property you had mapped
before is still mapped the moment the reconnected token starts working
again, with nothing to redo.
When the callback fails
Google redirects your browser back to this screen after you respond to the
consent prompt, and that redirect is the only path a broken connection
attempt takes, so it is worth knowing exactly what each failure on it does.
If Google's own redirect carries an error, or is missing the code or state
it should carry, the page never calls the backend at all: it bounces
straight back with a generic failure shown
(frontend/src/routes/(app)/settings/integrations/google/callback/+server.js).
Otherwise it hands the code and state to the backend, which can reject the
attempt for one of three reasons: the state token does not match what was
issued for you or has expired, Google refused to exchange the code, or
Google did not return a refresh token at all, which happens when you had
already granted access before and this attempt did not force a fresh
consent screen
(api/routers/integrations.py#oauth_complete). The frontend recognizes all
three of those reasons by name
(frontend/src/routes/(app)/settings/integrations/google/callback/+server.js#known),
but only two get their own message: a rejected code and a missing refresh
token each explain what happened and what to do next, while an expired
state token and anything unrecognized both collapse into the same generic
"try again" message. A successful callback carries no such detail: it
redirects with a plain success flag that shows a one-time confirmation.
Not gated by plan
Neither connecting nor mapping checks your plan at any point. This screen
calls twelve handlers: reading the connection's status, starting the
handshake for either product, disconnecting, listing discovered Search
Console properties, listing discovered Analytics properties, mapping and
unmapping one of each, and reading and writing the conversion-event list. A
thirteenth, completing the handshake, is reached from Google's redirect
rather than from the screen itself. Every one of them reads your identity;
mapping a property, unmapping one that currently has a project, and saving a
conversion-event list additionally read your role on that project, and
reading the conversion-event list requires membership on it. None of the
thirteen reads a plan or a subscription at
all (api/routers/integrations.py#integration_status, #oauth_start,
#ga4_oauth_start, #oauth_complete, #disconnect, #list_properties,
#list_ga4_properties, #map_property_to_project, #unmap_property,
#map_ga4_property_to_project, #unmap_ga4_property,
#get_conversion_events, #update_conversion_events). That puts this
screen outside every gate described on
Plans and limits: what you can connect and
map here is the same regardless of which plan governs the project you are
mapping into.
Related
- Search performance: what a mapped Search Console property actually feeds, once it has one
- Traffic: what a mapped Analytics property feeds beyond the AI-referred slice
- AI traffic: the AI-referral reading of the same Analytics connection, including how naming a conversion event changes it
- Troubleshooting: the connected-but-unmapped and mapped-but-empty states this page's central distinction explains
Last verified 2026-08-11