How do I link existing user accounts to an OAuth login?
Short answer
Keep a table that maps each provider’s user id, the sub claim, to your own user id. Start the link from a page where the person is already signed in, send state and PKCE, and save the link on your server only after you exchange the code. Refuse a provider account that is already linked to someone else, and never link accounts by email.
Your app has its own accounts, and you are adding a login through an OAuth 2.0 or OpenID Connect provider. The provider knows each person by its own id, and your app knows them by yours. Linking stores the connection between the two, made by the person, from a page where they are already signed in.
The implementation has four parts: a table, a link flow, a save on the server, and rules for conflicts and unlinking.
What does the data model for linked accounts look like?
An identities table maps one provider account to one of your users. Its key is the provider’s issuer plus the sub claim, the provider’s id for the person. OpenID Connect promises that a sub is never reassigned within its issuer and is at most 255 ASCII characters long. It also says only iss and sub together identify a person reliably (OpenID Connect Core, section 5.7).
create table identities (
issuer text not null, -- the provider, like https://accounts.example.com
sub text not null, -- the provider's id for the person
user_id bigint not null references users (id) on delete cascade,
label text, -- what settings shows, like their username there
linked_at timestamptz not null default now(),
primary key (issuer, sub), -- one provider account opens one user
unique (user_id, issuer) -- one account per provider per user
);The primary key stops one provider account from opening two of your accounts. The second constraint is a product choice: drop it if people may link two accounts from the same provider. Auth.js’s database adapters use the same shape: the Prisma schema’s Account model is unique on provider and providerAccountId, and points to a userId.
Don’t key anything on email. Addresses change, and an unverified one proves nothing.
How does the link flow work?
A link flow is the standard authorization code flow, in which the browser brings back a one-time code that your server exchanges for tokens. It differs in two ways: it starts from a signed-in page, and it ends by saving a link instead of starting a session.
- The person clicks Connect on your settings page.
- Your server creates a random
stateand a PKCEcode_verifier. It keeps both in the session, with the user’s id and a note that this flow is a link. - The browser goes to the provider’s authorization endpoint with
state,code_challengeandcode_challenge_method=S256. - The person signs in at the provider and approves. The provider sends the browser back with
codeandstate. - Your callback checks
state, checks that the same user is still signed in, exchanges the code with the verifier, and saves the link.
state is a random, single-use value that ties the callback to the browser that started the flow. PKCE (Proof Key for Code Exchange, RFC 7636) protects the code itself. Your server sends a hash of a secret at the start and the secret itself at the exchange, so a code taken from someone else’s flow fails.
app.get("/auth/callback", async (req, res) => {
const flow = req.session.oauthFlow; // { state, verifier, mode, userId }, saved at the start
req.session.oauthFlow = null; // single use
if (!flow || req.query.state !== flow.state) return res.status(400).send("This request expired. Try again.");
if (flow.mode !== "link") return finishSignIn(req, res, flow); // your normal sign-in branch
if (req.query.error) return res.redirect("/settings/sign-in?error=cancelled");
if (req.session.userId !== flow.userId) return res.status(400).send("Sign in again to connect.");
const tokens = await exchangeCode(String(req.query.code), flow.verifier); // with your client secret
const { iss, sub } = await verifyIdToken(tokens.id_token); // signature, iss, aud and exp
const owner = await db.identities.find({ issuer: iss, sub });
if (owner && owner.user_id !== flow.userId) return res.redirect("/settings/sign-in?error=taken");
if (!owner) await db.identities.insert({ issuer: iss, sub, user_id: flow.userId });
res.redirect("/settings/sign-in?connected=1");
});exchangeCode and verifyIdToken stand for your OpenID Connect library. Note where each value comes from: the user id from your session, the sub from the verified token, and nothing from the URL except code and state.
Why save the link on the server after the code exchange?
The code exchange is the first moment your server learns, from the provider itself, which account the person used. A code in the callback URL is only a string the browser brought along. Your server redeems it with the client secret and the PKCE verifier, and the provider checks that the verifier matches the challenge from the start of this flow. A code from anyone else’s flow fails at that point, so a link saved earlier would skip the check.
The server is the only place the decision can live, because everything the browser sends can be changed. Never accept a user id or a sub from a query parameter or a form field.
Skipping state is the classic mistake. An attacker can then feed their own authorization code to a signed-in victim’s callback, a cross-site request forgery that RFC 6749, section 10.12, describes. In a link flow, the attacker’s provider account ends up on the victim’s account, and the attacker can sign in as the victim. The security side of connecting accounts covers this attack and others.
Let the database have the last word too. With the primary key on (issuer, sub), two requests racing to link the same provider account can’t both succeed.
How do you handle conflicts when linking?
Linking conflicts come in two kinds, and both deserve a clear message rather than a silent fix.
The provider account is already linked to another user. Refuse. Moving it would let whoever controls that provider account take it from the other user, or merge two people by mistake. Ask the person to sign in to the other account and disconnect it there, or to use a different provider account. Firebase Authentication behaves the same way: linking fails when the credential already belongs to another user account.
The user already has an account from this provider. Decide once. Either allow several, and drop the unique (user_id, issuer) constraint, or ask the person to disconnect the old one first. Replacing it silently surprises people who use both.
A third case is a session that changed mid-flow, for example because someone else signed in on the same browser. The userId check in the callback catches it.
How do you unlink an account safely?
Unlinking deletes the row, with a few precautions around it.
- Never remove the last way in. If the person has no password and no other linked account, ask them to add one first. Supabase Auth, for example, only unlinks an identity when the user has at least two.
- Ask for a recent sign-in. Unlinking changes how someone gets into the account, so treat it like a password change.
- Revoke stored tokens. If you kept the provider’s access or refresh token, revoke it at the provider’s revocation endpoint (RFC 7009) and delete it.
- Say what changes. After unlinking, that provider account no longer opens this account.
Never link by email automatically, after an unlink or at any other time. A matching address proves only that someone typed it, unless the provider verified it. Auth.js keeps email linking off by default and names the option that turns it on allowDangerousEmailAccountLinking.
How loginos does it
loginos keeps the link on its side, so you need no identities table. Instead of storing the loginos sub, you give loginos your own user id, and every token carries it back.
- Push the request. When the signed-in user clicks “Link loginos”, your server calls
/oauth/parwith the usual parameters plusexternal_id, your id for that user. PAR (pushed authorization requests, RFC 9126) sends the parameters over the back channel, so the browser never holds your user id and can’t swap it. PKCE is required. - Approve. The person signs in to loginos, or creates an account, and sees both accounts side by side before approving.
- Commit on redemption. loginos makes the link when your server redeems the code at
/oauth/token, not when the person clicks approve. - Read
external_id. Theuserobject from the token endpoint, the ID token and/oauth/userinfocarryexternal_idfrom then on.
// app/settings/loginos/link/route.ts
import { createHash, randomBytes } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
import { currentUser } from "@/lib/session"; // your own
const LOGINOS = "https://loginos.io";
const b64url = (b: Buffer) => b.toString("base64url");
const basic = "Basic " + Buffer.from(`${process.env.LOGINOS_APP_KEY}:${process.env.LOGINOS_APP_SECRET}`).toString("base64");
export async function GET(req: NextRequest) {
const me = await currentUser(req);
if (!me) return NextResponse.redirect(new URL("/login", req.url));
const state = b64url(randomBytes(16));
const verifier = b64url(randomBytes(32));
const res = await fetch(`${LOGINOS}/oauth/par`, {
method: "POST",
headers: { authorization: basic },
body: new URLSearchParams({
response_type: "code",
redirect_uri: `${req.nextUrl.origin}/api/auth/login/single/callback`,
scope: "openid profile email",
state,
code_challenge: b64url(createHash("sha256").update(verifier).digest()),
code_challenge_method: "S256",
external_id: me.id,
external_label: me.email,
}),
});
if (!res.ok) return new Response("Couldn't start the link.", { status: 502 });
const { authorize_url } = await res.json();
const go = NextResponse.redirect(authorize_url);
go.cookies.set("lo_link", `${state}.${verifier}`, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 1800 });
return go;
}loginos also settles the conflicts. If the person’s loginos account is already linked to another of your users, loginos stops and offers to use another loginos account. If your external_id was linked to a different loginos account, the new link replaces it, because your server started the request. GET /api/v1/users?external_id=u_1842 finds a linked member, and DELETE /api/v1/users/{sub}/external-id unlinks one. The link guide has every edge case, and the /oauth/par reference lists the parameters.
Frequently asked questions
Can one user link more than one OAuth provider?
Yes. Each provider account is one row in the identities table, and all of them point to the same user id. Show every linked provider in settings with its own disconnect button, and keep at least one way to sign in.
Is the provider’s sub the same in every app?
Not always. OpenID Connect allows pairwise identifiers: a different sub for each client, so apps can’t track people across each other. If your apps use several client ids with one provider, check which kind it issues before they share an identities table. In loginos, sub never changes and is the same on all domains of your platform.
Should I store the provider’s access token when linking?
Only if you will call the provider’s API for the person. For sign-in, the issuer and sub are all you need. Tokens you store are secrets: encrypt them, and revoke and delete them when the person disconnects.
What should happen when someone signs in with a provider account that isn’t linked?
Don’t create a second account silently, and don’t attach the login to a user with the same email. Ask whether they already have an account. If they do, they sign in the old way and link from settings, as in the SSO rollout plan. If not, create the new account and its identity row together.
One account for every domain you run
loginos is an OpenID Connect provider with passkeys, a user pool shared by all of your domains, roles in every token, and account linking for the users you already have. Add your platform and its domains in a few minutes.
Keep reading
- Existing usersHow can users connect their existing account to a new login provider?Give signed-in users a Connect button in their account settings. It sends them to the provider to sign in and approve, then back to your settings page with a clear confirmation. Your server makes the link only after it checks state and exchanges the code, and it never matches accounts by email. Then show which account is connected, with a way to disconnect it.7 min read
- Existing usersHow do I add SSO to an app that already has users?Add SSO as a second way into the accounts you already have, not as a new user base. Let signed-in users connect the new login from their settings, so your app stores the provider’s user id against their account. Keep passwords working while people link, then decide whether to retire them. Never match accounts by an unverified email. With loginos, every token then carries your own user id.7 min read
- Existing usersHow do I migrate existing users to a new login system without making them sign up again?Move the accounts, not the people. Bulk-import users with their password hashes if the new system can verify your hash format, or migrate each person at their next login by checking the old password once. You can also skip the migration: keep your accounts and let signed-in users link the new login. loginos takes that last path, so people keep their account and connect loginos once.6 min read
- Sign-in across domainsHow do I share a login session across multiple domains?You can’t share a session cookie between two different domains, because browsers only send a cookie to the domain that set it. Instead, each domain keeps its own session and gets it from one central login, an OpenID Connect provider. Someone who is already signed in there is sent straight back with a one-time code, without seeing a form.6 min read