From Passwords to Protocols: A Developer's Guide to Modern Authentication
Authentication is one of those topics where most developers learn just enough to use a library, and then move on. That works — until it doesn't. When something breaks, when you need to integrate with a third-party identity provider, or when a security review asks why your token validation works the way it does, you realize you're standing on ground you don't fully understand.
This article starts from scratch. We'll go from the basics of how tokens work, through the problems that arise when your app grows, and all the way to how OAuth2 and OpenID Connect actually work step by step — including the things most tutorials skip over.
Table of Contents
Monolith vs. Microservices — Why Architecture Affects Auth {#monolith-vs-microservices}
Before talking about tokens and keys, it helps to understand the two broad ways applications are structured, because auth works differently in each.
Monolithic Architecture
A monolith is a single application where everything — user management, payments, notifications, recommendations — lives in the same codebase and is deployed as one unit. When you want to check if a user is logged in, your code just calls an internal function. There's no network involved.
User Request → Monolith App
├── auth module (verify token)
├── order module
└── payment module
Auth is straightforward here. One secret key, one place to verify tokens.
Microservices Architecture
Microservices splits the same functionality into separate, independently deployed services. You might have an auth-service, a booking-service, a payment-service, and a notification-service — each running as its own application.
User Request → API Gateway
├── auth-service (port 3001)
├── booking-service (port 3002)
└── payment-service (port 3003)
Now when the booking-service receives a request, it needs to verify: "Is this token legitimate? Who sent it?" But it doesn't have the user database — that lives in the auth-service. This creates a real question: how do services verify tokens without all sharing the same secret?
That question is what drives the rest of this article.
Symmetric Authentication {#symmetric-authentication}
Symmetric authentication is the simpler approach. It uses a single secret key to both create (sign) and verify tokens.
Here's the flow:
User logs in with their username and password
Server checks the credentials against the database
If valid, server creates a JWT and signs it using a secret key
User receives the token and sends it with every subsequent request
Server verifies the token using the same secret key
const jwt = require('jsonwebtoken');
const SECRET_KEY = 'a-long-random-secret-string';
// When user logs in — create a token
const token = jwt.sign(
{ userId: 123, email: 'user@example.com' },
SECRET_KEY,
{ expiresIn: '1h' }
);
// On every protected request — verify the token
try {
const payload = jwt.verify(token, SECRET_KEY);
console.log(payload.userId); // 123
} catch (err) {
// Token invalid or expired
res.status(401).json({ error: 'Unauthorized' });
}
This works perfectly for a single application. The issue appears when you have multiple services.
The Problem with Symmetric Auth in Microservices
If your booking-service wants to verify a token, it needs the secret key. So you share the key with it. Then you share it with the payment-service. Then the notification-service. Now six services all have the same secret key.
If any one of those services is compromised — say a security vulnerability in the notification-service — the attacker has the key that signs and verifies every token across your entire system. They can create tokens for any user, including admins.
The notification-service being hacked should not give someone admin access to payments. That's the core problem.
The Network Hop Problem {#the-network-hop-problem}
One alternative to sharing the key is to keep it only in the auth-service, and have every other service call the auth-service whenever it needs to verify a token. This is called a network hop.
booking-service receives request with token
|
|-- HTTP call --> auth-service: "Is this token valid?"
|<-- HTTP response -- auth-service: "Yes, userId = 123"
|
booking-service continues processing
This keeps the secret key in one place. But now every single authenticated request in your entire system requires an extra HTTP call to the auth-service.
Consider what this means at scale:
1,000 requests per second to your app = 1,000 extra calls to
auth-serviceIf
auth-serviceis slow, every request across every service gets slowIf
auth-servicegoes down, nothing in your entire system can authenticate anything
This makes the auth-service what's called a single point of failure. The auth-service being slow or unavailable now affects your booking, payment, and every other service simultaneously.
Neither option — shared secret or network hop — is ideal. That's what asymmetric authentication solves.
Asymmetric Authentication {#asymmetric-authentication}
Asymmetric authentication uses two mathematically linked keys instead of one:
A private key that only the auth service knows, used to sign tokens
A public key that any service can have, used to verify tokens
The mathematical relationship between them means that a token signed with the private key can be verified with the public key — but knowing the public key tells you nothing about the private key. You can verify without being able to forge.
auth-service:
- Has private key → signs tokens
- Exposes public key at GET /public-key
booking-service:
- Fetches and caches public key at startup
- Verifies every token locally using public key
- Zero calls to auth-service needed
payment-service:
- Same — fetches and caches public key
- Verifies tokens locally
Each service fetches the public key once at startup and stores it in memory. From then on, token verification is a local operation — no network call, no shared secret, no single point of failure.
If the notification-service is compromised, the attacker only gets the public key. The public key is already public — that's the point. They can verify tokens, but they cannot create new ones, because creating tokens requires the private key which only the auth-service has.
Public and Private Keys {#public-and-private-keys}
Keys in asymmetric cryptography are just very large numbers with a specific mathematical relationship to each other. The most common algorithm you'll encounter is RSA (Rivest–Shamir–Adleman).
A private key looks something like this in PEM format:
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA2a2rwplBQLF29amygykEMmYz0+Kcj3bKBp29K...
(many lines of base64-encoded data)
-----END RSA PRIVATE KEY-----
The corresponding public key:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a2rwplBQL...
-----END PUBLIC KEY-----
When you sign a JWT with the private key, the signature is computed over both the header and the payload. If anyone tampers with the payload — for example, changing "role": "user" to "role": "admin" — the signature becomes invalid. The public key verification will fail and the token will be rejected.
const jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('./private.pem');
const publicKey = fs.readFileSync('./public.pem');
// auth-service: sign with private key
const token = jwt.sign(
{ userId: 123, role: 'user' },
privateKey,
{ algorithm: 'RS256', expiresIn: '1h' }
);
// booking-service: verify with public key
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
JWKS — How Public Keys Are Shared in Practice
Rather than sharing a raw PEM file, the standard way to expose public keys is via a JWKS endpoint (JSON Web Key Set). The URL is typically something like GET /keys/public-key or more standardly GET /.well-known/jwks.json.
The response is a JSON object containing an array of keys:
{
"keys": [
{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": "cc413527-173f-5a05-976e-9c52b1d7b431",
"n": "w4M936N3XNaEb1cUoBm...",
"e": "AQAB"
}
]
}
What each field means:
| Field | Meaning |
|---|---|
kty |
Key type. RSA is most common. EC (elliptic curve) is also used. |
alg |
Algorithm. RS256 means RSA with SHA-256 hashing. |
use |
What this key is for. sig means signing/verification. enc means encryption. |
kid |
Key ID. An identifier for this specific key. |
n |
The RSA modulus — the actual large number that forms the key, base64url-encoded. |
e |
The public exponent. Almost always AQAB, which decodes to 65537. |
The kid field is especially useful. Every JWT has a header section, and that header contains the kid of the key that was used to sign it. When a service receives a token, it reads the kid from the header, finds the matching key in its cached JWKS, and uses that key to verify.
This allows key rotation — the practice of periodically replacing your keypair with a new one. You publish both the old and new keys in your JWKS. Old tokens reference the old kid and are verified with the old key. New tokens reference the new kid. Once all old tokens have expired, you remove the old key. Zero downtime, zero broken tokens.
What is OIDC and Why Does it Exist {#what-is-oidc-and-why-does-it-exist}
Once you understand asymmetric tokens, the next question is: why use Google or GitHub for authentication at all? Why not just build your own auth service?
You can. Many companies do. But when you want to let users log in with an existing provider (Google, GitHub, Microsoft), you need a standardized way to talk to that provider. Without a standard, integrating with Google would look completely different from integrating with GitHub, which would look different from integrating with Okta.
This is the problem that OpenID Connect (OIDC) solves.
OIDC is a protocol — a set of rules that any identity provider can implement. If a provider is OIDC-compliant, you already know exactly which URLs it exposes, what format its tokens are in, and how to verify them. The same client code works against Google, Microsoft, Okta, Keycloak, and any other compliant provider.
OAuth2 vs OIDC
These two are often confused because OIDC is built on top of OAuth2.
OAuth2 is an authorization protocol. It answers: "Can this application do X on behalf of this user?" It results in an access token that lets your app call APIs on the user's behalf. For example, OAuth2 lets Spotify post to your Twitter or lets a third-party app read your Google Calendar.
OIDC adds authentication on top of OAuth2. It answers: "Who is this user?" It gives you an ID token — a JWT containing the user's identity information (their user ID, email, name). When you click "Sign In with Google" on a website, OIDC is what's happening under the hood.
In practice, when you implement "Sign In with Google", you get both an access token (to call Google APIs) and an ID token (to know who the user is).
SAML vs OIDC
You'll hear about SAML in enterprise contexts. SAML is an older standard that does similar things — federated authentication across systems — but uses XML rather than JSON. It's verbose, complex to implement, and predates mobile apps. Most modern systems prefer OIDC, but SAML is still common in large enterprises because it's deeply integrated into legacy infrastructure. If a job description mentions SAML, it usually means corporate SSO with systems like Active Directory or older IdPs.
Service Discovery and the Well-Known Configuration {#service-discovery}
Now, a practical problem: if you want to integrate with Google's auth, how do you know which URL to redirect users to? How do you find the token endpoint? Where do you get the public keys?
You could hardcode these URLs. But if Google changes them, your integration breaks silently.
OIDC solves this with a discovery document — a standardized URL that every OIDC-compliant provider must expose:
GET /.well-known/openid-configuration
This endpoint returns a JSON document that describes everything about the provider: all its endpoints, supported algorithms, supported scopes, and more. Your application fetches this once at startup and uses it to configure itself dynamically.
For Google, the URL is:
https://accounts.google.com/.well-known/openid-configuration
Here is an abbreviated version of what that returns (from Google's official documentation):
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"device_authorization_endpoint": "https://oauth2.googleapis.com/device/code",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"response_types_supported": ["code", "token", "id_token", "code token", "code id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "email", "profile"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"claims_supported": ["aud", "email", "email_verified", "exp", "family_name", "given_name",
"iat", "iss", "locale", "name", "picture", "sub"]
}
Let's go through each important field:
issuer
This is the base URL of the identity provider. "https://accounts.google.com" means every token issued by Google will have "iss": "https://accounts.google.com" in its payload.
When your service receives a token and verifies it, it checks that the iss claim matches the expected issuer. This prevents a token issued by one provider from being accidentally accepted by a service that trusts a different provider.
authorization_endpoint
This is where you redirect the user's browser when they click "Sign In with Google." The user will see Google's login page here if they're not already logged in, and the consent screen asking what permissions they're granting.
token_endpoint
This is where your backend server sends the authorization code (received from the redirect) to exchange it for actual tokens. This is a server-to-server call — the user's browser never directly talks to this endpoint.
userinfo_endpoint
After you have an access token, you can call this endpoint to get the user's profile information — name, email, profile picture, and other details depending on what scopes you requested. An alternative to calling this endpoint is simply decoding the ID token, which already contains this information.
jwks_uri
The URL where you can fetch Google's current public keys in JWKS format. Your backend fetches these to verify the signatures on ID tokens. Google rotates these keys periodically, so you should cache them with a reasonable TTL and re-fetch when you encounter a token with an unknown kid.
device_authorization_endpoint
Used for the Device Authorization Flow — for applications like CLIs or TV apps that can't do browser-based redirects. We'll cover this later.
claims_supported
The list of user data fields that can appear in tokens or be fetched from the userinfo_endpoint. Standard claims include sub (a unique, stable user identifier), email, email_verified, name, picture, and locale information.
Client ID and Client Secret {#client-id-and-client-secret}
Before your application can use Google (or any OIDC provider) for authentication, you need to register it. With Google, this happens in the Google Cloud Console. You fill out a form with:
Application name — what users will see on the consent screen
Application URL — your website
Redirect URIs — the exact URLs Google is allowed to redirect back to after authentication
After registration, Google gives you two things:
Client ID — a public identifier for your application. It looks something like 123456789-abc.apps.googleusercontent.com. This is safe to include in frontend code, because it only identifies your application — it doesn't prove anything.
Client Secret — a credential that proves your server is actually your application. It looks like a random string: GOCSPX-abc123.... This must never appear in frontend code, browser logs, or version control.
The reason the client secret must stay on the server is covered in detail when we walk through the full flow below. The short version: the client secret is what prevents anyone who intercepts the authorization code from using it to get tokens.
The Full OAuth2 Authorization Code Flow {#the-full-oauth2-flow}
This is the standard flow used when a user clicks "Sign In with Google" on a regular web application. We'll use abc.com as our example application throughout.
There are three parties involved:
The User — the person with a browser
abc.com — the application the user is trying to log into (your app)
Google — the identity provider
Step 0 — Registration (done once, before any users)
The developer of abc.com goes to the Google Cloud Console and registers the application. They specify that the valid redirect URI is https://abc.com/auth/callback. Google issues a client_id and client_secret.
client_id = "abc-app.apps.googleusercontent.com"
client_secret = "GOCSPX-xyz123..." (stored on server, never exposed)
Step 1 — User clicks "Sign In with Google"
The user visits abc.com and clicks the login button. abc.com's frontend constructs a URL to Google's authorization_endpoint and redirects the user's browser there.
Redirect to:
https://accounts.google.com/o/oauth2/v2/auth
?client_id=abc-app.apps.googleusercontent.com
&redirect_uri=https://abc.com/auth/callback
&response_type=code
&scope=openid email profile
&state=k8f3mN9qP2xL5rT7
Let's understand each parameter:
| Parameter | Value | Purpose |
|---|---|---|
client_id |
Your app's public ID | Tells Google which app is requesting login |
redirect_uri |
https://abc.com/auth/callback |
Where Google should send the user after login. Must match a pre-registered URI exactly. |
response_type |
code |
Tells Google to return an authorization code (not a token directly) |
scope |
openid email profile |
What user information you're requesting access to |
state |
Random string | A CSRF protection value. You generate this, store it in the user's session, and check it when Google redirects back |
Step 2 — User Authenticates with Google
The user's browser is now on Google's domain. If they're not already logged in, they see Google's login form and enter their credentials. Google handles all of this — abc.com never sees the user's Google password.
After login, Google shows a consent screen: "abc.com wants to access your name, email address, and profile picture." The user clicks Allow.
Step 3 — Google Redirects Back with an Authorization Code
Google redirects the user's browser back to the redirect_uri specified in Step 1:
GET https://abc.com/auth/callback
?code=4/0AcvDMr...short_lived_code...
&state=k8f3mN9qP2xL5rT7
Two things in this redirect:
code — a short-lived, one-time-use authorization code. It expires in approximately 10 minutes and can only be used once. This code alone does nothing — it only becomes useful when combined with the client secret in the next step.
state — the same random value that was sent in Step 1. abc.com's server checks that this matches what it stored in the user's session. If it doesn't match, the request is rejected. This prevents an attack where a malicious site tricks a user's browser into completing someone else's auth flow.
Step 4 — abc.com's Backend Receives the Code
The redirect_uri points to abc.com's backend. The code arrives here. The backend now needs to exchange this code for actual tokens. But before it does, it:
Verifies the
stateparameter matches what was stored in the sessionNotes that the code is a one-time value and will be sent to Google exactly once
Step 5 — Backend Exchanges Code for Tokens (Server to Server)
abc.com's backend makes a POST request directly to Google's token_endpoint. This is a server-to-server request — it does not go through the user's browser at all.
POST https://oauth2.googleapis.com/token
Body:
code = 4/0AcvDMr...short_lived_code...
client_id = abc-app.apps.googleusercontent.com
client_secret = GOCSPX-xyz123...
redirect_uri = https://abc.com/auth/callback
grant_type = authorization_code
This request combines three pieces of information:
The code that proves the user just authenticated with Google
The
client_idandclient_secretthat prove this isabc.com's server making the requestThe
redirect_uriagain, as an additional verification that nothing changed
Google verifies all of this. The code is valid. The client secret matches the registered application. The redirect URI matches what's registered.
Step 6 — Google Returns Tokens
Google's token_endpoint responds with a JSON body:
{
"access_token": "ya29.a0AfH6SMB...",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "openid email profile",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImNjNDEzNTI3....",
"refresh_token": "1//0eXp_k..."
}
What each token is:
access_token — a credential that lets you call Google APIs on behalf of the user. For example, you can use it to call the userinfo_endpoint to fetch the user's profile. It expires in expires_in seconds (typically 3600, one hour).
id_token — a JWT signed by Google with their private key. When you decode it, its payload contains the user's identity:
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"azp": "abc-app.apps.googleusercontent.com",
"aud": "abc-app.apps.googleusercontent.com",
"iat": 1714567890,
"exp": 1714571490,
"email": "user@example.com",
"email_verified": true,
"name": "Priya Sharma",
"picture": "https://lh3.googleusercontent.com/...",
"given_name": "Priya",
"family_name": "Sharma",
"locale": "en"
}
The sub field (subject) is the user's permanent, unique identifier within Google's system. This is the value you should use as the foreign key when storing user records in your database — not the email address, which can change.
refresh_token — a long-lived token used to get new access tokens after they expire, without requiring the user to log in again. This is only returned if you requested the offline_access scope (or configured your OAuth app to always return it). Refresh tokens should be stored securely on the server and never sent to the browser.
Step 7 — Backend Verifies the ID Token
abc.com's backend should not blindly trust the id_token. It needs to verify it:
const { OAuth2Client } = require('google-auth-library');
const client = new OAuth2Client(CLIENT_ID);
async function verifyIdToken(idToken) {
const ticket = await client.verifyIdToken({
idToken: idToken,
audience: CLIENT_ID,
});
const payload = ticket.getPayload();
// Verify issuer
if (payload.iss !== 'https://accounts.google.com') {
throw new Error('Invalid issuer');
}
// Verify audience (this token was issued for our app)
if (payload.aud !== CLIENT_ID) {
throw new Error('Invalid audience');
}
// Verify expiry (the library does this, but worth knowing)
if (payload.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
return payload;
}
Internally, the library fetches Google's public keys from jwks_uri, finds the key matching the kid in the token header, and verifies the signature mathematically.
Step 8 — Backend Creates a Session
After verifying the ID token, abc.com creates or updates a user record in its own database using the sub as the identifier. Then it creates its own session for the user — typically by issuing its own JWT or setting a session cookie.
const payload = await verifyIdToken(idTokenFromGoogle);
// Create or update user in database
let user = await db.users.findOne({ googleId: payload.sub });
if (!user) {
user = await db.users.create({
googleId: payload.sub,
email: payload.email,
name: payload.name,
picture: payload.picture,
});
}
// Issue our own session token
const sessionToken = jwt.sign(
{ userId: user.id },
OUR_PRIVATE_KEY,
{ algorithm: 'RS256', expiresIn: '7d' }
);
res.cookie('session', sessionToken, { httpOnly: true, secure: true });
res.redirect('/dashboard');
From this point on, abc.com uses its own token to authenticate the user across its own services. Google's tokens are only involved in the login step.
Complete Flow Summary
User abc.com Frontend abc.com Backend Google
| | | |
|-- click login -------->| | |
| | | |
|<-- redirect to --------| | |
| google.com/auth? | | |
| client_id=abc& | | |
| redirect_uri=... | | |
| | | |
|-------- logs in and approves consent ------------------>| |
| | | |
|<------- redirect to abc.com/callback?code=XYZ ----------| |
| | | |
|-- GET /callback? | | |
| code=XYZ ----------->| | |
| |-- send code to ---->| |
| | backend | |
| | |-- POST /token -->|
| | | code=XYZ |
| | | + client_secret|
| | | |
| | |<-- id_token -----|
| | | access_token |
| | | refresh_token |
| | | |
| | | verify id_token |
| | | create/find user |
| | | issue session |
| | | |
|<------- session cookie / JWT -----------------| |
| | | |
| (user is now logged into abc.com) | |
The Man-in-the-Middle Problem {#man-in-the-middle}
You might wonder: why not have Google redirect directly with the token instead of this code-exchange dance? Why not just:
https://abc.com/callback?access_token=REAL_TOKEN
The answer is that URLs are not private. They appear in:
Browser history
Server access logs
Referrer headers when navigating to other pages
Browser extensions and proxies
If a real access token appeared in a URL, anyone with access to any of those logs would have a working token.
The authorization code is different. It has two properties that make it safe in a URL:
It expires quickly — typically within 10 minutes
It's useless without the client secret — even if someone captures the code, they cannot exchange it for tokens without also having
client_secret, which is stored only onabc.com's backend server
So even if an attacker captures code=XYZ from a URL, they can't do anything with it unless they also have the client secret. The client secret lives only on the server and never appears in any URL or browser-accessible context.
Validating the Redirect URI
Another protection is that Google only redirects to URLs you pre-registered. If an attacker tries to modify the redirect_uri parameter to point to their own server:
redirect_uri=https://evil.com/capture
Google checks this against the list of registered URIs for your client_id. If it doesn't match exactly — including scheme, domain, path — Google rejects the request entirely. The authorization code never gets issued.
This is why you register redirect URIs in the Developer Console before your app can work.
Device Authorization Flow {#device-authorization-flow}
Some applications can't do browser-based redirects. A CLI tool, a Smart TV app, or a game console don't have a browser that can navigate to accounts.google.com and redirect back. The Device Authorization Flow handles this case.
The full specification is RFC 8628.
How it Works
The device requests a pair of codes from the device_authorization_endpoint:
POST https://oauth2.googleapis.com/device/code
Body:
client_id = abc-cli.apps.googleusercontent.com
scope = openid email
Google responds with:
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "GQVN-JQPD",
"verification_url": "https://www.google.com/device",
"expires_in": 1800,
"interval": 5
}
The device displays a message to the user:
Visit https://www.google.com/device
Enter code: GQVN-JQPD
Meanwhile, the device polls the token endpoint every interval seconds:
POST https://oauth2.googleapis.com/token
grant_type = urn:ietf:params:oauth:grant-type:device_code
device_code = GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS
client_id = abc-cli.apps.googleusercontent.com
While the user hasn't yet approved, Google responds with authorization_pending. Once the user visits the URL, enters the code, and approves on their phone or laptop, the next poll returns the actual tokens.
This is exactly how gh auth login, gcloud auth login, and similar CLI tools work.
Single Sign-On {#single-sign-on}
Single Sign-On (SSO) is the experience where authenticating once with a central identity provider gives you access to multiple separate applications without re-entering credentials.
When you're logged into Gmail and then open YouTube, you're already logged in there too. When an employee logs into their company's Google Workspace account and then opens Slack, Notion, and GitHub — all pre-configured for SSO — they don't re-enter their password for each one.
How the Session Reuse Works
When a user authenticates at accounts.google.com, Google sets a session cookie on the accounts.google.com domain. This cookie persists in the browser.
Later, when that user visits youtube.com and YouTube initiates an OIDC flow by redirecting to accounts.google.com/o/oauth2/v2/auth, the browser automatically sends that existing session cookie along with the redirect. Google sees the valid session, skips the login UI, and immediately issues an authorization code back to YouTube. The user never sees a login prompt.
User visits YouTube
|
YouTube redirects to Google's authorization_endpoint
|
Browser sends existing accounts.google.com session cookie
|
Google: "This user already has a valid session"
|
Google immediately redirects to YouTube with authorization code
|
YouTube backend exchanges code for tokens
|
User lands on YouTube — logged in
Enterprise SSO
In corporate environments, SSO typically uses an Identity Provider (IdP) like Okta, Azure Active Directory, or Google Workspace. All company applications — Slack, GitHub, Salesforce, internal tools — are configured to delegate authentication to this IdP.
When a new employee joins, an IT admin creates one account in the IdP. That account grants access to all connected applications. When an employee leaves, the IT admin disables their IdP account. Access to every connected application is revoked simultaneously — no need to log into 20 different systems and deactivate each one manually.
Libraries like NextAuth.js, Passport.js, and services like Clerk, Auth0, and Okta are implementations of this entire system. Understanding what's described in this article makes you a much more effective user of those libraries — you know what they're doing internally and can debug problems when they arise.
Additional Concepts Worth Knowing {#additional-concepts}
Token Scopes and the Principle of Least Privilege
When you make an authorization request, you ask for specific scopes — defined permissions. Common OpenID Connect scopes include:
| Scope | What it grants access to |
|---|---|
openid |
Required for OIDC. Indicates you want an ID token. |
email |
User's email address and email_verified status |
profile |
User's name, picture, locale, and other basic profile info |
offline_access |
A refresh token so you can act on the user's behalf without them being present |
You should request only the scopes your application actually needs. If your app only needs to know who the user is for login purposes, request openid email — not everything available. An access token with scope email cannot access the user's Drive files even if the user has granted Drive access to other apps. The scopes define the token's capabilities.
PKCE — For Applications That Can't Store Secrets
A regular web application keeps its client_secret on the server. But a mobile app or single-page application (SPA) can't securely store secrets — the code runs on the user's device, and anyone can inspect it.
PKCE (Proof Key for Code Exchange, pronounced "pixie") solves this. It's defined in RFC 7636.
The flow works like this:
Before initiating auth, the app generates a random string called the
code_verifierIt hashes this string (SHA-256) to produce a
code_challengeIt sends the
code_challengein the authorization requestWhen exchanging the code for tokens, it sends the original
code_verifierThe auth server hashes the verifier and checks it matches the challenge it stored
const crypto = require('crypto');
// Step 1 — generate random verifier
const codeVerifier = crypto.randomBytes(32).toString('base64url');
// Step 2 — hash it to produce challenge
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Step 3 — include in authorization URL
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth
?client_id=${CLIENT_ID}
&redirect_uri=${REDIRECT_URI}
&response_type=code
&scope=openid email
&code_challenge=${codeChallenge}
&code_challenge_method=S256`;
// Later — send verifier during token exchange
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
code_verifier: codeVerifier, // original, not hashed
}),
});
If an attacker intercepts the authorization code, they still can't exchange it — they'd need the code_verifier, which was only ever in the app's memory and was never transmitted. The recommendation from current OAuth 2.0 Security Best Practices (RFC 9700) is to use PKCE for all public clients, and it's increasingly recommended for confidential clients (regular web apps) as well.
Refresh Tokens and Token Rotation
Access tokens are intentionally short-lived — typically 1 hour. When one expires, you use the refresh token to get a new one:
POST https://oauth2.googleapis.com/token
Body:
grant_type = refresh_token
refresh_token = 1//0eXp_k...
client_id = abc-app.apps.googleusercontent.com
client_secret = GOCSPX-xyz123...
Google responds with a new access token (and sometimes a new ID token). The refresh token itself may or may not rotate — some providers issue a new refresh token with each refresh request and invalidate the old one. This is called refresh token rotation, and it helps detect token theft: if an old refresh token is used after it's been rotated out, the server can detect that two parties are using what should be a single-use token and revoke the entire session.
Refresh tokens should be stored server-side or in httpOnly cookies — never in localStorage, where they'd be accessible to JavaScript and vulnerable to XSS attacks.
The sub Claim — Use This as Your User ID
The sub (subject) claim in an ID token is a stable, unique identifier for the user within the identity provider's system. It will never change even if the user changes their email address.
You might be tempted to use the email as your primary user identifier in your database. Don't. Users change their email addresses. If you use email as the foreign key and a user updates their email with Google, your entire user record association breaks. Use sub as the stable external identifier.
Token Introspection
If you're validating tokens from external services and can't (or don't want to) do local JWT verification with public keys, OAuth2 defines a token introspection endpoint (RFC 7662):
POST /introspect
Body:
token = ya29.a0AfH6SMB...
The auth server responds with whether the token is active and its claims. This is essentially the same as the "network hop" approach described earlier, so it's only appropriate in specific circumstances (opaque tokens that can't be locally verified, or when you need real-time revocation checking).
Clock Skew
JWTs contain exp (expiry time) and iat (issued at time) claims as Unix timestamps. When you validate a token, you compare these against the current time. The problem is that servers' clocks are never perfectly synchronized — one server might be a few seconds ahead or behind another.
If a token was issued at exactly 12:00:00 and you check it at 11:59:59 on a server with a slightly behind clock, it would appear to be issued in the future. Most JWT libraries allow you to configure a clockTolerance (usually 60 seconds) to handle this gracefully.
Token Revocation
JWTs are stateless by design — the server doesn't store them. This means there's no built-in way to revoke a specific token before it expires. If a user logs out or changes their password, their old access token is still technically valid until it expires.
Mitigation strategies:
Use short access token lifetimes (15 minutes, not 24 hours)
Maintain a revocation list (blocklist) for sensitive operations, checking it on each request
Use the OAuth2 revocation endpoint (
revocation_endpointin the discovery document) to inform the provider that the token should be considered invalid
POST https://oauth2.googleapis.com/revoke
Body:
token = the_refresh_token_to_revoke
Hands-On Task {#hands-on-task}
The most effective way to understand this is to go through the flow manually, without a library doing it for you.
Part 1 — Explore the discovery document
Open your browser and go to:
https://accounts.google.com/.well-known/openid-configuration
Read every field. Then open the URL in jwks_uri. You'll see RSA public keys in JWKS format. Compare the structure against what was described in this article.
Also try:
https://token.actions.githubusercontent.com/.well-known/openid-configuration
https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
Every OIDC provider exposes this same endpoint — notice how the structure is identical across providers. That's standardization working.
Part 2 — Register an OAuth application
Go to console.cloud.google.com. Create a project. Go to APIs & Services → Credentials → Create Credentials → OAuth Client ID. Choose "Web application". Set an authorized redirect URI to http://localhost:3000/callback. Note the client_id and client_secret you receive.
Part 3 — Trigger the authorization flow manually
Construct the authorization URL by hand. Replace the values:
https://accounts.google.com/o/oauth2/v2/auth
?client_id=YOUR_CLIENT_ID
&redirect_uri=http://localhost:3000/callback
&response_type=code
&scope=openid email profile
&state=test123
Paste it in your browser. Log in. After approval, you'll be redirected to localhost:3000/callback?code=...&state=test123. Copy the code from the URL.
Part 4 — Exchange the code for tokens
Use curl to exchange the code. Do this quickly — the code expires in a few minutes:
curl -X POST https://oauth2.googleapis.com/token \
-d "code=THE_CODE_FROM_STEP_3" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "redirect_uri=http://localhost:3000/callback" \
-d "grant_type=authorization_code"
You'll receive a JSON response with an id_token. Copy the value.
Part 5 — Decode and verify the ID token
Go to jwt.io and paste the id_token. In the decoded payload, you'll see your email, name, sub, and other claims. Notice the kid in the header.
Now go back to the jwks_uri from the discovery document:
https://www.googleapis.com/oauth2/v3/certs
Find the key with the matching kid. That's the specific key Google used to sign your token.
Part 6 — Write a simple verification
In Node.js:
const { OAuth2Client } = require('google-auth-library');
const client = new OAuth2Client('YOUR_CLIENT_ID');
async function verify(idToken) {
const ticket = await client.verifyIdToken({
idToken: idToken,
audience: 'YOUR_CLIENT_ID',
});
const payload = ticket.getPayload();
console.log('User ID (sub):', payload.sub);
console.log('Email:', payload.email);
console.log('Name:', payload.name);
}
verify('PASTE_YOUR_ID_TOKEN_HERE');
When this runs successfully, you've gone through the complete OIDC flow manually — from authorization request to verified identity.
Closing
Authentication is one of those areas where the surface looks simple but the implementation details matter quite a bit. Most of the time, using a well-maintained library is the right call — they handle edge cases, security updates, and spec compliance that you'd otherwise need to track continuously.
But understanding what those libraries are doing — the reasons for each step in the flow, the security properties of each design choice — means you can integrate with identity providers correctly, debug problems when they occur, and make informed decisions about configuration options. The choice between a 1-hour and a 15-minute access token lifetime, whether to use refresh token rotation, whether PKCE applies to your use case — these are decisions that matter, and they make more sense once you understand the underlying model.
References and further reading:
