Sign people in with Google
SocialScope can run "Continue with Google" for your app, so you can sign people up or in without operating your own Google OAuth client. Your backend starts an identity attempt, your page auto-posts a one-use launch token to SocialScope, the person approves at Google, and your backend redeems the verified result exactly once. You then create or find your own user and start your own session. This flow is separate from connecting a YouTube channel: it creates no SocialScope user, no connection and no YouTube access.
How it differs from Connect
| Google identity | Social Connect | |
|---|---|---|
| Needs an existing app user | No. Only a tenant key and a return URL | Yes, a scope with externalSubjectId |
| Google permissions | openid email profile |
Provider read permissions |
| Result | Verified claims, returned once to your backend | A durable connection |
| Return hint | ss_identity_id |
ss_session_id |
| Launch path | /api/identity/redeem |
/api/connect/redeem |
A person can run both flows at the same time in different tabs.
Before you start
- An admin enables Google identity for your client and registers your identity return URL, for example
https://app.example.com/auth/google/return. The URL must be on one of your registered origins and must not contain any query parameter starting withss_. integrationReadiness.googleIdentityAvailableistrue. If it isfalseor the readiness call fails, hide or disable the Google button.- Your server has
SOCIALSCOPE_GRAPHQL_URLandSOCIALSCOPE_CONSUMER_KEY. See Authentication. - Pull request preview deployments cannot use Google identity. SocialScope serves per-PR preview hosts through one shared preview client registered with a host template instead of exact origins, and that client is limited to Connect. See Testing.
The examples import socialscope from lib/socialscope.ts. Copy that file from Authentication. It keeps extensions.code and retryAfterSeconds on every error, which you need for RATE_LIMITED.
The flow
sequenceDiagram
autonumber
actor B as Browser
participant App as Your backend
participant SS as SocialScope
participant G as Google
B->>App: Continue with Google
App->>SS: createGoogleIdentitySession(tenantKey, returnUrl)
SS-->>App: id, launchUrl, launchToken, resultProof
App->>App: Save id, tenantKey, resultProof with the browser session
App-->>B: no-store page that auto-posts launchToken
B->>SS: POST /api/identity/redeem (Origin = your origin)
SS-->>B: SocialScope page names your app, then Google
B->>G: Sign in and approve
G-->>B: Redirect to SocialScope callback
B->>SS: Callback (SocialScope verifies the ID token)
SS-->>B: 303 to returnUrl?ss_identity_id={id}
B->>App: GET returnUrl?ss_identity_id={id}
App->>SS: redeemGoogleIdentityResult(id, tenantKey, resultProof)
SS-->>App: status, issuer, googleSub, email, emailVerified, name, pictureUrl
App->>App: Find or create user by issuer + googleSub, then start your session
1. Start the attempt
There may be no user yet, so bind the attempt to the browser session instead, for example a short-lived, signed, HttpOnly pre-login cookie. Your server picks the tenant and return URL from its own configuration.
import { socialscope } from "./lib/socialscope";
import { launchPage } from "./lib/socialscope-launch";
const START_GOOGLE_IDENTITY = `
mutation StartGoogleIdentity($input: GoogleIdentityInput!) {
createGoogleIdentitySession(input: $input) {
id
launchUrl
launchToken
resultProof
expiresAt
resultExpiresAt
}
}
`;
type IdentitySession = {
id: string;
launchUrl: string;
launchToken: string;
resultProof: string;
expiresAt: string; // authorization deadline, 10 minutes after creation
resultExpiresAt: string; // redeem deadline, 20 minutes after creation
};
// `browserSessionId` identifies the pre-login browser session. `identityAttempts` is your server-side store.
export async function startGoogleSignIn(browserSessionId: string) {
const tenantKey = "example-workspace";
const returnUrl = "https://app.example.com/auth/google/return";
const { createGoogleIdentitySession: attempt } = await socialscope<{ createGoogleIdentitySession: IdentitySession }>(START_GOOGLE_IDENTITY, { input: { tenantKey, returnUrl } });
// resultProof never leaves the server: not to the browser, a URL or a log.
await identityAttempts.insert({
id: attempt.id,
browserSessionId,
tenantKey,
resultProof: attempt.resultProof,
resultExpiresAt: attempt.resultExpiresAt,
used: false,
});
return launchPage(attempt.launchUrl, attempt.launchToken, "/api/identity/redeem");
}
Errors you can get here:
| Code | Meaning | What to do |
|---|---|---|
CAPABILITY_UNAVAILABLE |
Google identity is not available in this SocialScope environment right now | Hide the button. Readiness will say false too |
FORBIDDEN |
Google identity is not enabled for your app, or the tenant is not yours | Ask the admin |
BAD_USER_INPUT |
The return URL is not exactly registered | Fix the configured return URL |
RATE_LIMITED |
Your app already has 20 unfinished attempts | Wait extensions.retryAfterSeconds (1 to 600) before retrying |
2. Send the browser
Use the same launch page as Connect, with the identity path. Serve it from the origin of your identity return URL.
// lib/socialscope-launch.ts
// Server only. Returns a standard Response, usable as-is in a Next.js route handler.
type LaunchPath = "/api/connect/redeem" | "/api/identity/redeem";
const ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ESCAPES[char]);
export function launchPage(launchUrl: string, launchToken: string, expectedPath: LaunchPath): Response {
const connectOrigin = new URL(process.env.SOCIALSCOPE_GRAPHQL_URL!).origin;
const launch = new URL(launchUrl);
if (launch.origin !== connectOrigin || launch.pathname !== expectedPath || launch.search || launch.hash) {
throw new Error("Unexpected SocialScope launch URL");
}
if (!/^[0-9a-f]{64}$/.test(launchToken)) throw new Error("Unexpected SocialScope launch token");
const html = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Continuing</title></head>
<body>
<form method="post" action="${escapeHtml(launchUrl)}">
<input type="hidden" name="launchToken" value="${escapeHtml(launchToken)}">
<button type="submit">Continue</button>
</form>
<script>document.forms[0].submit()</script>
</body>
</html>`;
return new Response(html, {
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
"referrer-policy": "strict-origin",
"content-security-policy": `default-src 'none'; script-src 'unsafe-inline'; form-action ${connectOrigin}; base-uri 'none'`,
"x-content-type-options": "nosniff",
},
});
}
SocialScope shows a page naming your app and asks the person to continue to Google. After Google, it redirects to your return URL with only ss_identity_id. No claims, code or token ever appear in the URL.
3. Redeem the result
At your return route, require the same browser session that started the attempt and match the hint to its saved unused attempt. Redeem once from your server with the saved tenant and proof, then mark the attempt used unless the status is PENDING, which does not consume the proof. The browser reaches this route through a cross-site redirect from SocialScope, so the pre-login cookie you check must be SameSite=Lax or None. A SameSite=Strict cookie is not sent on that request.
import { socialscope } from "./lib/socialscope";
const REDEEM_GOOGLE_IDENTITY = `
mutation RedeemGoogleIdentity($id: ID!, $tenantKey: String!, $resultProof: String!) {
redeemGoogleIdentityResult(id: $id, tenantKey: $tenantKey, resultProof: $resultProof) {
status
issuer
googleSub
email
emailVerified
name
pictureUrl
}
}
`;
type IdentityResult = {
status: "PENDING" | "COMPLETED" | "DENIED" | "FAILED" | "EXPIRED";
issuer: string | null;
googleSub: string | null;
email: string | null;
emailVerified: boolean | null;
name: string | null;
pictureUrl: string | null;
};
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function googleReturn(url: URL, browserSessionId: string) {
const hints = url.searchParams.getAll("ss_identity_id");
if (hints.length !== 1 || !UUID.test(hints[0])) return { outcome: "retry" as const };
const saved = await identityAttempts.findUnused({ id: hints[0], browserSessionId });
if (!saved || new Date(saved.resultExpiresAt) <= new Date()) return { outcome: "retry" as const };
const { redeemGoogleIdentityResult: person } = await socialscope<{ redeemGoogleIdentityResult: IdentityResult }>(REDEEM_GOOGLE_IDENTITY, { id: saved.id, tenantKey: saved.tenantKey, resultProof: saved.resultProof });
// PENDING does not consume the proof. Every other status does.
if (person.status === "PENDING") return { outcome: "pending" as const };
await identityAttempts.markUsed(saved.id);
if (person.status !== "COMPLETED" || !person.issuer || !person.googleSub) {
return { outcome: "not-signed-in" as const, status: person.status };
}
// Key the user by issuer + googleSub. Email can change and is never a linking key.
const user = await users.findOrCreateByGoogle({
issuer: person.issuer,
googleSub: person.googleSub,
email: person.email,
emailVerified: person.emailVerified === true,
name: person.name,
pictureUrl: person.pictureUrl,
});
// Start your session only after the user write succeeded.
return { outcome: "signed-in" as const, userId: user.id };
}
Rules:
- Only
COMPLETEDwith a non-emptyissuerandgoogleSubcan create or sign in a user.issuerishttps://accounts.google.com. - Key your user by issuer plus Google subject in your own database. Email may change, so never link accounts by email alone.
- Check
emailVerifiedbefore treating the email as verified. - Create your app's session only after your own user write succeeds.
DENIED,FAILEDandEXPIREDcreate no user. Offer a retry.PENDINGmeans the authorization is still running. Redeem again shortly, until the 20-minute result deadline.- The proof redeems once. A second redeem returns
NOT_FOUNDand must never sign anyone in. - Strip
ss_identity_idfrom the URL before analytics or third-party scripts load. - If the browser session that started the attempt has expired, have the person start again rather than accepting the hint.
Statuses
| Status | Meaning | Proof consumed |
|---|---|---|
PENDING |
Still running and the 10-minute authorization deadline has not passed | No |
COMPLETED |
Verified. Claims are returned once | Yes |
DENIED |
The person cancelled at Google | Yes |
FAILED |
The Google exchange or verification failed | Yes |
EXPIRED |
The authorization deadline passed | Yes |
redeemGoogleIdentityResult returns NOT_FOUND for a second redeem, a wrong proof, a wrong tenant, another app's attempt, a result past its 20-minute deadline, or when identity was turned off for your app. These are deliberately indistinguishable.
Failure and recovery
| What happened | What your app sees | What to do |
|---|---|---|
| The launch form was blocked or the tab closed | No return. A redeem gives PENDING, then EXPIRED after 10 minutes |
Start a new attempt |
| The person cancelled at Google | Return with the hint. Redeem gives DENIED |
Show cancelled. Offer a retry |
| Google's code exchange failed | The browser may stay on SocialScope's error page and never return | Redeem from your server with the saved attempt. You get FAILED or EXPIRED. Offer a retry |
| The person reloaded SocialScope's page after starting | SocialScope says the request could not be verified | Start again from your app |
| The network dropped during your redeem call | The proof may already be consumed and the claims gone | Start a new attempt |
| Identity was turned off mid-flow | Later steps fail. Redeem gives NOT_FOUND |
Start again once it is re-enabled |
What Google identity does not do
- It does not create a SocialScope user or session. Your app owns both.
- It does not grant YouTube access. Use Connect with
YOUTUBEfor that. - It does not store any Google access or refresh token.