Skip to main content

Authentication and Authorization

From the CTO — Required reading for all product and engineering teams. Authentication and authorization are not features. They are the foundation of every system we build. Get them wrong and everything above them is compromised.


Table of Contents

  1. Why This Matters
  2. The Standard at a Glance
  3. Session Management: JWT
  4. Third-Party Integrations: OAuth 2.0
  5. Enterprise SSO: SAML 2.0
  6. Authorization: Roles and Permissions
  7. Forbidden Patterns
  8. Quick Reference

Why This Matters

Authentication confirms who a user is. Authorization confirms what they can do. Both fail more often than teams expect, and the consequences are severe.

Sessions get hijacked

Weak or long-lived tokens are a permanent backdoor. A JWT signed with a weak secret, a token that never expires, or a token stored in localStorage (accessible to any JavaScript on the page) gives an attacker permanent access to a user's account — even after the user changes their password. Unlike a database breach, a stolen token leaves no trace in your application logs.

Integrations break under real-world conditions

Homebrew OAuth flows have subtle bugs. OAuth 2.0 has specific states, redirects, and token exchanges. Teams that implement their own flows frequently miss the state parameter (CSRF on the redirect), skip PKCE on public clients (auth code interception), or store access_token in a cookie without HttpOnly (XSS exposure). These bugs do not appear in testing. They appear when an attacker looks for them.

Authorization is checked inconsistently

Missing a single check exposes the resource. Authorization logic scattered across controllers, services, and middleware leads to gaps. One endpoint that forgets to check a role, one API route that assumes the frontend will restrict access — these are horizontal privilege escalation vulnerabilities. They are the most common class of auth bug in production systems.

Architectural warning: Never implement custom authentication protocols. Do not write your own session tokens, your own OAuth server, or your own SAML parser. Use the approved libraries listed in this guide. The attack surface of a homebrew auth system is always larger than the team expects.


The Standard at a Glance

Use caseProtocolNotes
User sessions (first-party)JWT (RS256)Short-lived access token + rotating refresh token
Third-party login / API accessOAuth 2.0 (Authorization Code + PKCE)PKCE required for all public clients
Enterprise SSOSAML 2.0SP-initiated flow only
Service-to-service (internal)mTLS + short-lived JWTSee Encryption Standards for mTLS
Password storagebcrypt / argon2Never encrypt passwords — hash them

Session Management: JWT

JSON Web Tokens are the standard for first-party user sessions. A JWT is a signed, self-contained token that encodes the user's identity and claims.

Algorithm: RS256, not HS256

We sign JWTs with RS256 (asymmetric RSA + SHA-256). The private key signs the token; the public key verifies it. Services that only need to verify a token never touch the private key.

Do not use HS256 (symmetric HMAC). HS256 uses a single shared secret — any service that verifies a token can also forge one. This is unacceptable in a multi-service architecture.

AlgorithmKey typeWho can forge a token?Status
RS256RSA key pairOnly the holder of the private keyRequired
ES256EC key pairOnly the holder of the private keyAllowed
HS256Shared secretAny verifierForbidden

Token structure

ClaimValueNotes
subUser IDOpaque identifier — not email or username
issService URLe.g. https://auth.open-kerno.com
audTarget servicee.g. https://api.open-kerno.com
expUnix timestampAccess token: 15 minutes max
iatUnix timestampWhen the token was issued
jtiUUIDUnique token ID — required for revocation

Token lifetime and refresh

Short-lived access tokens are non-negotiable. A 15-minute window limits the blast radius of a stolen token.

Token typeLifetimeStorage
Access token15 minutesMemory only (not localStorage, not a cookie)
Refresh token7 days, rotatingHttpOnly, Secure, SameSite=Strict cookie

Rotating refresh tokens: each use of a refresh token issues a new refresh token and invalidates the previous one. If the old token is used again (replay attack), revoke the entire refresh token family immediately.

// ✅ Correct — RS256, short expiry, required claims
import jwt from 'jsonwebtoken';
import fs from 'fs';

const privateKey = fs.readFileSync('./keys/private.pem');

const accessToken = jwt.sign(
{ sub: userId, aud: 'https://api.open-kerno.com', jti: crypto.randomUUID() },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m', issuer: 'https://auth.open-kerno.com' }
);

// ❌ Wrong — HS256 with a hardcoded secret
const token = jwt.sign({ userId }, 'my-secret', { algorithm: 'HS256' });

Verification

Every service that accepts a JWT must verify all of the following:

  1. Signature — using the public key, not by skipping verification.
  2. exp — token is not expired.
  3. iss — token comes from our auth service.
  4. aud — token is intended for this service.
// ✅ Correct — full verification
const publicKey = fs.readFileSync('./keys/public.pem');

const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.open-kerno.com',
audience: 'https://api.open-kerno.com',
});

// ❌ Wrong — skipping algorithm enforcement
const payload = jwt.verify(token, publicKey); // allows algorithm confusion attacks

Rule: Never call jwt.decode() for authorization decisions. decode() does not verify the signature. Use jwt.verify() always.


Third-Party Integrations: OAuth 2.0

OAuth 2.0 is the protocol for delegated authorization — letting users grant our application access to their data on another service (Google, GitHub, Slack) without sharing their password.

Required flow: Authorization Code + PKCE

We use the Authorization Code flow with PKCE (Proof Key for Code Exchange) for all OAuth integrations. PKCE is required for any client that cannot securely store a client secret — this includes SPAs and mobile apps, but we apply it universally as a defense-in-depth measure.

Do not use:

  • Implicit flow — deprecated in OAuth 2.1, returns tokens in the URL fragment.
  • Client Credentials flow for user-facing logins — it is for machine-to-machine, not users.
  • Resource Owner Password Credentials (ROPC) — requires the user to hand over their password to your app.

PKCE flow

1. Generate a random `code_verifier` (43–128 chars, URL-safe)
2. Compute `code_challenge` = BASE64URL(SHA256(code_verifier))
3. Send user to the provider's authorization URL with:
- `response_type=code`
- `client_id`
- `redirect_uri`
- `scope`
- `state` (random, stored in session — CSRF protection)
- `code_challenge`
- `code_challenge_method=S256`
4. Provider redirects back with `code` and `state`
5. Verify `state` matches what you stored
6. Exchange `code` + `code_verifier` for tokens at the token endpoint
7. Store `access_token` in memory; store `refresh_token` in HttpOnly cookie
// ✅ PKCE — generate verifier and challenge
import crypto from 'crypto';

function generatePKCE() {
const verifier = crypto.randomBytes(64).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}

// Store verifier in server-side session before redirect
// Send challenge + method=S256 in the authorization URL

State parameter — mandatory

The state parameter prevents CSRF on the OAuth redirect. Generate a random value, store it server-side (session or signed cookie), and reject any callback where state does not match.

// ✅ Generate and validate state
const state = crypto.randomBytes(16).toString('hex');
session.oauthState = state; // store before redirect

// On callback:
if (req.query.state !== session.oauthState) {
throw new Error('OAuth state mismatch — possible CSRF');
}

Token storage

TokenStorage
access_tokenIn-memory only. Never localStorage.
refresh_tokenHttpOnly, Secure, SameSite=Strict cookie
client_secretAWS Secrets Manager — never in code or .env committed to git

Enterprise SSO: SAML 2.0

SAML 2.0 is the protocol for enterprise single sign-on. Enterprises connect their identity provider (Okta, Azure AD, Google Workspace) to our service provider.

SP-initiated flow only

We support Service Provider (SP) initiated flows. The user starts at our login page, we redirect to their IdP, and they return with a signed assertion. We do not support IdP-initiated flows — they are harder to secure against open redirect and CSRF attacks.

Assertion validation — mandatory steps

Every SAML response must pass all of the following checks before the user is authenticated:

CheckWhat to verify
SignatureResponse and/or Assertion signed by the IdP's certificate
CertificateIdP certificate matches the one registered during setup
Conditions.NotBefore / NotOnOrAfterAssertion is within its valid time window
AudienceRestrictionAudience matches our entity ID
InResponseToMatches the ID from the AuthnRequest we sent (replay prevention)
DestinationMatches our ACS URL exactly
// ✅ Use a maintained SAML library — do not parse XML manually
import { ServiceProvider, IdentityProvider } from 'samlify';

const sp = ServiceProvider({ entityID: 'https://app.open-kerno.com', ... });
const idp = IdentityProvider({ metadata: idpMetadataXml });

const { extract } = await sp.parseLoginResponse(idp, 'post', req);
// samlify validates signature, time window, audience, and InResponseTo

Architectural warning: Never parse SAML XML manually with a generic XML parser. XML signature validation has subtle implementation requirements (exclusive canonicalization, enveloped signature transform). Use a maintained library that has been audited for XXE and signature-wrapping attacks.

Attribute mapping

Map IdP attributes to internal user fields explicitly. Do not trust unverified attributes for authorization decisions.

IdP attributeInternal fieldNotes
NameIDuser.samlIdPersistent identifier — use as the primary key
emailuser.emailTreat as display only, not as a login key
groupsMapped to rolesMap IdP groups to our roles at provisioning time

Authorization: Roles and Permissions

Authentication tells us who the user is. Authorization tells us what they can do. These are separate concerns and must be handled separately.

Role-based access control (RBAC)

Define roles at the application level. Roles are sets of permissions. Users are assigned roles. Permissions are checked at the resource level.

RoleExample permissions
viewerread:reports, read:dashboard
editorread:*, write:reports, write:content
adminread:*, write:*, delete:*, manage:users
serviceSpecific API permissions only — no user-facing resources

Where to check permissions

Authorization checks belong in the service layer, not only in middleware or the frontend.

// ✅ Check at the service layer — the source of truth
async function deleteReport(userId: string, reportId: string) {
const user = await getUser(userId);
if (!user.permissions.includes('delete:reports')) {
throw new ForbiddenError('Insufficient permissions');
}
return reportRepository.delete(reportId);
}

// ❌ Wrong — checking only in the route handler
router.delete('/reports/:id', requireRole('admin'), handler);
// If the same logic is called from another path, the check is skipped

Never trust client-supplied roles

Role and permission data must come from your own database or the verified JWT payload — not from the request body, query string, or any client-supplied header.

// ❌ Wrong — role from request body
const { role } = req.body;
if (role === 'admin') { ... }

// ✅ Correct — role from verified JWT
const { sub } = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
const user = await db.users.findById(sub);
if (user.role === 'admin') { ... }

Forbidden Patterns

These patterns are blocked at code review. Finding one in existing code is a required remediation item.

PatternWhy it is forbiddenUse instead
jwt.decode() for auth decisionsDoes not verify signaturejwt.verify() with full options
HS256 JWT signingAny verifier can forge tokensRS256 or ES256
OAuth Implicit flowToken in URL fragment — visible in logs and historyAuthorization Code + PKCE
ROPC (password grant)User hands their password to your appAuthorization Code + PKCE
Storing tokens in localStorageAccessible to any JS (XSS)Memory for access token; HttpOnly cookie for refresh
Tokens with exp > 24 hours (access)Limits revocation options15 minutes max
Homebrew session tokensWeak randomness, timing attacks, no revocationUse JWT with the approved library
Parsing SAML XML manuallyXXE and signature-wrapping attacksUse samlify or equivalent
IdP-initiated SAMLOpen redirect and CSRF risksSP-initiated only
Trusting client-supplied rolesPrivilege escalationRead from database / JWT only

Auditing your codebase

# Find jwt.decode() used for auth decisions
grep -rn "jwt\.decode\b" src/ --include="*.ts"

# Find HS256 usage
grep -rn "HS256\|algorithm.*hs256" src/ --include="*.ts" -i

# Find localStorage token storage
grep -rn "localStorage.*[Tt]oken\|localStorage.*[Aa]ccess" src/ --include="*.ts"

# Find Implicit flow (response_type=token)
grep -rn "response_type.*token\b" src/ --include="*.ts"

Quick Reference

// JWT — sign (RS256, 15m)
const token = jwt.sign(
{ sub: userId, jti: crypto.randomUUID(), aud: 'https://api.open-kerno.com' },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m', issuer: 'https://auth.open-kerno.com' }
);

// JWT — verify (full validation)
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.open-kerno.com',
audience: 'https://api.open-kerno.com',
});

// OAuth PKCE — generate challenge
const verifier = crypto.randomBytes(64).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');

// ✅ access_token → memory only
// ✅ refresh_token → HttpOnly, Secure, SameSite=Strict cookie
// ✅ client_secret → AWS Secrets Manager
// ✅ SAML → samlify, SP-initiated, validate all assertion fields

// ❌ jwt.decode() for auth | HS256 | localStorage | Implicit flow | ROPC
TaskStandard
User session tokenJWT, RS256, 15 min access + 7 day rotating refresh
Verify a JWTjwt.verify() with algorithms, issuer, audience
Third-party login (Google, GitHub)OAuth 2.0 Authorization Code + PKCE
Enterprise SSOSAML 2.0, SP-initiated, samlify
Store access tokenMemory only
Store refresh tokenHttpOnly + Secure + SameSite=Strict cookie
Store client secretAWS Secrets Manager
Check permissionsService layer, from DB or verified JWT — never from client

Questions or edge cases not covered here? Open a discussion in the #engineering-standards channel.