Side reading
OIDC in one page
Every parameter in an OpenID Connect login, what it is for, and which one is causing your error.
OAuth 2.0 is an authorisation framework: it gets an application permission to do something. OpenID Connect is a thin layer on top that adds identity: it tells the application who you are. When you configure "sign in with", you are configuring OIDC.
The parameters #
issuer identifies the provider, as a URL. Everything else is discovered from it: fetch <issuer>/.well-known/openid-configuration and you get every endpoint and the location of the signing keys. Get this right and most of the rest is automatic.
client_id identifies the application. Not secret.
client_secret authenticates a confidential client when it talks directly to the provider. Secret. Mobile and other public clients cannot keep one.
redirect_uri is where the provider may send the user back. Register and match it exactly, including scheme, port and trailing slash.
scope is what the application is asking for. openid is required and is what makes it OIDC rather than plain OAuth. profile adds name and username, email adds the address.
state is a random value the application generates and checks on return, which stops an attacker feeding your browser a login they started.
nonce is a random value that ends up inside the token, so the application can confirm the token was minted for this login and not replayed.
code_challenge / PKCE binds the authorisation code to the client that requested it. Use authorisation code plus PKCE for public and confidential clients when both products support it.
The tokens #
ID token. A JWT: three base64url segments separated by dots, holding a header, claims and a signature. The client verifies the signature with the provider's published keys and checks the exact issuer, intended audience/client ID, expiry and nonce. Decoding the segments performs none of those validations.
Access token. A credential for calling APIs on the user's behalf. Often opaque. Not for identifying the user, though plenty of code misuses it that way.
Refresh token. Obtains new access tokens without asking the user again. Long-lived, and therefore the most dangerous of the three to leak.
What the claims mean #
sub is the subject: a stable, unique identifier for the user at this provider. This is the one to key accounts on.
email is not stable. People change addresses, and providers may reuse them.
email_verified says whether the provider checked the address. Applications that match accounts by email should require it, because otherwise someone registering with an address they do not own can take over an existing account.
groups is not standard. Providers put group membership in a claim by convention, and each application needs telling which claim to read.
Matching accounts by email is the common vulnerability
Application sees alice@example.com in a token, finds a local account with that address, logs you in as her.
That is fine when the provider verified the address and you control who can register. It is a takeover when either is untrue. Key on sub where the application lets you; require email_verified where it does not.
Reading an error #
Failures cluster into four kinds, and each has a signature.
redirect_uri_mismatch, or a provider page saying the redirect is not allowed. What is registered differs from what was sent, usually by a trailing slash, http against https, or a port. Compare them character by character.
invalid_client. The client_id or client_secret is wrong, or the secret has been rotated on one side only.
A redirect loop. The application authenticates, then does not believe its own session, and starts again. Usually cookie related: check that the application knows its own external URL, and that it is behind HTTPS if it expects to be. A reverse proxy that does not forward the original scheme causes exactly this.
Logged in, then refused. Authentication worked and authorisation did not. A group binding, a required claim that is missing, or the application's own permissions. Look at the provider's logs: they will show a successful login for a user the application then rejected.
Checking a token by hand #
The provider's endpoints are all listed in one document:
$ curl -s '<auth-service-url>/application/o/immich/.well-known/openid-configuration' \
| python3 -m json.tool
A JWT can be decoded without any tooling, since the payload is base64. Decoding is not verifying: it tells you what the token claims, not whether the signature is good.
JWTs use base64url without padding, which plain base64 -d refuses, so translate the alphabet and pad it:
$ python3 -c 'import base64,json,sys; s=sys.argv[1]; print(json.dumps(json.loads(base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))), indent=2))' '<the middle segment>'
Use local test tokens only and do not paste them into a public decoder, chat or issue. This inspection can answer whether a claim exists; it cannot establish that the token is authentic or acceptable.