Contents

OAuth Is a Hotel Keycard, Not Your House Key

Why OAuth exists, what a token actually authorises, and where homelabbers get the scopes wrong

Contents

A hotel keycard opens exactly one door, works for exactly the nights you booked, and can be deactivated at the front desk without anyone touching the lock itself. Nobody hands you a copy of the house key that also happens to work on room 214. That distinction is the whole of OAuth, and it is also the thing almost everyone gets backwards the first time they wire up “Sign in with Google” or hook a homelab app into a third-party API. OAuth is fundamentally a protocol for handing out keycards, scoped and time-limited, so an app can act on your behalf without ever holding the key to your entire life — a delegation mechanism, doing a job most people mistakenly credit to login screens.

I have spent enough evenings debugging “invalid scope” errors and over-permissioned integrations to know the concept is simple and the implementation details are where people trip. This is the mental model that would have saved me a few of those evenings, plus the parts of the flow that matter when you are the one configuring the client, not just clicking “Allow” on someone else’s app.

Authentication and authorisation are different problems

Advertisement

Authentication answers “who are you”. Authorisation answers “what are you allowed to do”. A password proves identity — authentication. A keycard proves you are allowed into room 214 tonight — authorisation, with no claim about who is holding it. OAuth 2.0 is fundamentally an authorisation framework: it was built so that Application A can be granted specific, limited access to Application B’s resources, on your behalf, without A ever seeing your password for B.

The confusion starts because OpenID Connect, a thin identity layer bolted on top of OAuth 2.0, is what actually handles “who are you” for most “Sign in with X” buttons. OIDC adds an ID token — a signed claim about your identity — on top of OAuth’s access tokens. When people say “OAuth login” they usually mean OIDC riding on OAuth’s plumbing. The plumbing itself was never about proving identity; it was about granting scoped access to a resource without sharing a master credential.

The actors in the room

Every OAuth exchange has four parties, and giving them their real names clears up most of the confusion:

  • Resource owner — you, the person who owns the data.
  • Client — the application asking for access (a photo-printing site wanting your cloud photos).
  • Authorisation server — the service that issues tokens (Google’s, GitHub’s, or your own if you self-host one).
  • Resource server — the API that actually holds the data and checks the token before answering.

The client never talks to the authorisation server with your password. It redirects you to the authorisation server, you authenticate there directly, and the authorisation server hands the client a token afterwards. Your credentials never pass through the client’s hands at all — that is the entire point.

What a token actually contains

Advertisement

An OAuth access token is not a magic string; conceptually it is a claim: “this token, presented by this client, grants access to these scopes, for this user, until this time.” In practice it is one of two shapes. An opaque token is a random string the resource server has to look up against the authorisation server to validate — simple, revocable instantly, but adds a network round trip per check. A JWT-based token is self-contained and signed, so the resource server can verify it locally without calling home, at the cost of being harder to revoke early (it is valid until it expires, full stop, unless you build a separate revocation list).

1
2
3
GET /api/photos HTTP/1.1
Host: photos.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

That Bearer prefix is worth sitting with. A bearer token authorises whoever bears it — there is no additional proof of identity baked into the request. Anyone who steals that string can use it exactly as you could, for as long as it is valid. This is why token lifetimes, HTTPS everywhere, and short-lived access tokens paired with longer-lived refresh tokens matter more than any other detail in the spec.

Scopes are the keycard’s door list

Scopes are where “hotel keycard” earns its keep as a mental model. When a client requests access, it asks for specific scopes — photos.read, calendar.write, repo — and the authorisation server shows you exactly which doors it wants to open before you approve anything. A well-behaved client asks for the minimum it needs. A poorly-behaved one asks for admin because the developer could not be bothered to scope the request properly, and you click Allow without reading it, and now a recipe-import extension can delete your entire calendar.

This is the single most common mistake I see homelabbers make when they wire OAuth into a self-hosted app: they grant the broadest scope offered because the narrower ones are not clearly labelled, then wonder six months later why a barely-used integration has write access to everything. Read the scope list every time. If an app only needs to read your calendar, it should never hold a token that can also delete events.

The authorisation code flow, and why implicit grant died

The exchange that gets you a token has a specific shape, and understanding it explains a security fix the industry made a few years back. In the authorisation code flow, the client redirects you to the authorisation server, you log in and approve the scopes, and the authorisation server redirects you back with a short-lived, single-use code — not a token — in the URL. The client then exchanges that code for an access token in a separate, server-to-server call, using its client secret to prove it really is the registered client and not an impostor reusing an intercepted redirect.

That second step matters because a redirect URL is visible to browser history, referrer headers, and anything else watching the address bar. Handing back a code instead of a token means a leaked redirect is useless on its own — the code cannot be turned into a token without the client secret, which never travels through the browser. An older alternative, the implicit grant, returned the access token directly in the redirect URL, skipping that second exchange for the sake of simplicity in early single-page apps. It was deprecated for exactly the reason above: anything that could see the URL could see the token. Modern clients, including mobile and single-page apps that cannot safely hold a secret, now use the authorisation code flow with PKCE (Proof Key for Code Exchange) instead — the client generates a random secret at the start of the flow and proves it holds that secret at the token-exchange step, which closes the same hole without requiring a stored client secret at all.

If you are configuring a self-hosted OAuth client and see a checkbox for “PKCE” or “public client”, that is what it is for: public clients (ones that cannot keep a secret, like a JavaScript app or a mobile binary) should always use it, and most authorisation servers now require it by default for anything that is not a confidential, server-side client.

Refresh tokens: the key you keep at the front desk

Access tokens are deliberately short-lived — often an hour — because a stolen one should stop working quickly. But nobody wants to re-approve an app every sixty minutes, so OAuth adds a refresh token: a longer-lived credential, stored by the client, used only to request a new access token from the authorisation server without bothering you again. The refresh token itself is never sent to the resource server; it only ever talks to the authorisation server, which is one more reason the resource server never needs to see anything beyond the access token it is handed.

This is also the layer where revocation actually lives. Revoking access to an app means the authorisation server invalidates the refresh token; the current access token might survive until it naturally expires (opaque tokens can be killed immediately, JWTs typically cannot), but no new one gets issued after that.

Running your own authorisation server

If you self-host apps, at some point OAuth stops being something you consume and starts being something you provide — an SSO layer in front of everything in the rack, so your internally-hosted tools all trust one identity provider instead of maintaining separate logins. Tools like Authentik and Authelia exist for exactly this: they act as the authorisation server for your homelab, issuing tokens (or session cookies backed by the same principles) that your other services trust.

A minimal Authentik-fronted app config looks roughly like this once the provider is set up:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
services:
  authentik-server:
    image: ghcr.io/goauthentik/server:2024.10
    environment:
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
      AUTHENTIK_POSTGRESQL__HOST: postgres
      AUTHENTIK_REDIS__HOST: redis
    ports:
      - "9000:9000"
  myapp:
    image: example/myapp:latest
    environment:
      OAUTH_CLIENT_ID: ${MYAPP_CLIENT_ID}
      OAUTH_CLIENT_SECRET: ${MYAPP_CLIENT_SECRET}
      OAUTH_ISSUER: https://auth.mylab.local/application/o/myapp/

Once this is running, every app in your stack requests scoped, revocable access from one place instead of maintaining its own password database — which is the same benefit Google gets you when you click “Sign in with Google”, just under your own roof.

Troubleshooting the flow

Most OAuth bugs fall into a short list once you know what to check:

“Invalid redirect URI” means the client sent a callback URL that does not exactly match what is registered with the authorisation server — including trailing slashes and http vs https. Fix the registration, not the client, unless you control both.

Token works, then stops working exactly one hour later: that is an access token expiring as designed. The client should be using its refresh token to get a new one silently; if the user is being bounced back to a login screen instead, the refresh flow is broken or the refresh token itself expired from disuse.

“Insufficient scope” on an API call that used to work: the resource server tightened its scope requirements, or the token was issued before a scope was added to the client’s registration. Re-authorise to pick up the new scope; there is no way to upgrade an existing token’s permissions after the fact.

A revoked app can still make one more successful call: expected behaviour with JWT access tokens, which are valid until expiry unless you have built revocation checking. If instant kill-switches matter to you, prefer opaque tokens or short access-token lifetimes for anything sensitive.

“CSRF detected” or “state mismatch” during login: the state parameter sent in the initial redirect did not match what came back. This parameter exists precisely to stop an attacker tricking a victim into completing someone else’s login flow; if you are building your own client, never skip generating and checking it, and never reuse a static value across sessions.

A refresh token stops working with no warning: most authorisation servers cap how long a refresh token survives without use, often 30 to 90 days, and some rotate the refresh token itself on every use — meaning if a client fails to persist the newly-issued one, the next refresh attempt fails outright. Check your client’s token storage before assuming the authorisation server is at fault; a surprising number of “broken OAuth” bug reports are actually a client silently discarding the rotated token.

Is it worth understanding, or just clicking Allow?

For consuming OAuth as a user, the keycard model is enough: check the scopes before you approve, and revoke access for anything you no longer use, the same way you would not keep a keycard from a hotel you checked out of a year ago. For anyone self-hosting, understanding the flow properly pays off the first time an integration breaks in a way that “just log in again” does not fix — which is most of them. It also changes how you configure your own services once you put something like Authentik in front of everything in the rack: you start asking for the narrowest scope a service actually needs, rather than the broadest one on offer, which is the entire discipline in one sentence.

The keycard analogy has one limit worth naming: unlike a hotel, nobody at the front desk is checking your bag on the way out. A client that mishandles a token it was legitimately issued can still do damage within its scope. OAuth limits what a compromised credential can reach; it does not make the credential itself safe to leak. Treat access tokens with the same seriousness you would an SSH private key — because functionally, for the duration of their validity, that is exactly what they are.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.