loginos

How do I link existing user accounts to an OAuth login?

loginos teamUpdated 7 min read

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).

PostgreSQL
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.

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.

  1. The person clicks Connect on your settings page.
  2. Your server creates a random state and a PKCE code_verifier. It keeps both in the session, with the user’s id and a note that this flow is a link.
  3. The browser goes to the provider’s authorization endpoint with state, code_challenge and code_challenge_method=S256.
  4. The person signs in at the provider and approves. The provider sends the browser back with code and state.
  5. 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.

Express
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.

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.

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.

  1. Push the request. When the signed-in user clicks “Link loginos”, your server calls /oauth/par with the usual parameters plus external_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.
  2. Approve. The person signs in to loginos, or creates an account, and sees both accounts side by side before approving.
  3. Commit on redemption. loginos makes the link when your server redeems the code at /oauth/token, not when the person clicks approve.
  4. Read external_id. The user object from the token endpoint, the ID token and /oauth/userinfo carry external_id from 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

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

All answers