Authentication in 2026 has clarified. Passwords are out. Passkeys are the primary factor. OAuth 2.1 + OIDC is the federation standard. Sessions beat JWTs for most apps. The fragmented advice from 2018 (“just use JWTs!”) has aged badly; this post is the updated playbook.

I’ll cover the principles, the protocols, the mistakes, and concrete code for FastAPI, Django, Hono on Bun, and Next.js.

The decision space

Three orthogonal questions:

  1. What’s the user’s first factor? (Password, magic link, passkey, social, SSO.)
  2. How do you persist the session? (Cookie + server-side session, or JWT.)
  3. How do you let third parties act on the user’s behalf? (OAuth 2.1 + OIDC.)

Each has a 2026-correct answer. Let’s go.

First factor — passkeys, not passwords

A passkey is a public-key credential bound to a device (phone, hardware key, or platform like Face ID / Touch ID / Windows Hello). The user proves they have the device; the server verifies a signature.

Why it wins:

  • No shared secret. Server stores the public key. A breach reveals nothing useful.
  • Phishing-resistant. The credential is bound to the origin (example.com). It won’t fire on example-login.io.
  • No password to forget, lose, or reuse.
  • Cross-device sync via the user’s platform (iCloud, Google Password Manager, 1Password, Bitwarden).

The browser API is WebAuthn. The server sees a challenge/response dance. Auth.js, Clerk, FastAPI plugins, and Django libraries all wrap it cleanly.

The minimum passkey UX

  1. Sign-up: server generates a challenge → browser asks user to approve (Face ID etc.) → server stores the public key.
  2. Sign-in: server generates a challenge → browser signs it with the matching private key → server verifies.
  3. Fallback: if the user has no passkey on this device, send a magic link to their email (or use OAuth federation).

That’s it. Two flows.

Server-side sessions vs JWTs

In 2018 every blog said “use JWTs.” Eight years of postmortems later: for most apps, server-side sessions in HTTP-only cookies are the right default.

Server sessionJWT
RevocationEasy (delete row)Hard (need a denylist or short TTL)
Privilege updatesImmediateWait for expiry
StorageServer-side (Redis, Postgres)Stateless
Cross-domain APIsCookie + CORSToken in Authorization header
Mobile clientsCookie or tokenToken
SizeTiny IDLarger payload
VerificationDB/cache lookupLocal signature check

Use JWTs for:

  • Stateless service-to-service API auth.
  • Short-lived access tokens (e.g., OAuth access tokens that expire in 15 min).
  • Cases where cookies are inconvenient (mobile, cross-domain).

Use sessions for:

  • Web apps with browser-based users.
  • Cases where you might need to revoke instantly.
  • Cases where roles/permissions change at runtime.

The old “JWTs scale better” argument doesn’t hold up. Redis sessions add ~1ms; verification savings rarely matter at any sensible scale.

OAuth 2.1 — federation, not session storage

OAuth 2.1 (which consolidated OAuth 2.0 and BCPs) is for delegated access:

  • “Sign in with Google” (federation; usually paired with OIDC).
  • “Allow this app to read my GitHub repos” (third-party access).
  • Service-to-service auth with rotating tokens.

OAuth is not how you store user sessions. Common confusion: people use the OAuth access token as their session. Don’t. Treat the OAuth flow as a way to identify the user (via OIDC’s ID token), then create your own session.

OIDC at a glance

OpenID Connect (OIDC) is OAuth 2.0 + identity. The flow:

  1. User clicks “Sign in with Google.”
  2. Your backend redirects to Google with client_id, scope=openid profile email.
  3. User authenticates at Google.
  4. Google redirects back with a code.
  5. Your backend exchanges codeid_token + access_token.
  6. Verify id_token signature; extract sub (Google’s user ID) + email.
  7. Find or create user in your DB; create a session.

The id_token is a JWT signed by Google. Verify it (libraries handle this) and never trust unverified claims.

In 2026 use the PKCE extension for every OAuth flow, including server-side. It’s now mandatory in 2.1.

The four-stack walkthrough

FastAPI (Python)

The Authlib + Starlette sessions combination is clean for OIDC + sessions:

from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI, Request, HTTPException
from starlette.middleware.sessions import SessionMiddleware

app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"])

oauth = OAuth()
oauth.register(
    name="google",
    client_id=os.environ["GOOGLE_CLIENT_ID"],
    client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
    client_kwargs={"scope": "openid email profile"},
)


@app.get("/auth/login")
async def login(request: Request):
    redirect_uri = str(request.url_for("auth_callback"))
    return await oauth.google.authorize_redirect(request, redirect_uri)


@app.get("/auth/callback", name="auth_callback")
async def auth_callback(request: Request):
    token = await oauth.google.authorize_access_token(request)
    userinfo = token.get("userinfo")
    if not userinfo:
        raise HTTPException(400, "no userinfo")

    user = await get_or_create_user(email=userinfo["email"], provider_sub=userinfo["sub"])
    request.session["user_id"] = user.id      # ← server session via cookie
    return RedirectResponse(url="/")


@app.get("/me")
async def me(request: Request):
    uid = request.session.get("user_id")
    if not uid:
        raise HTTPException(401, "unauthenticated")
    return await get_user(uid)

For passkeys on FastAPI, webauthn (the official FIDO Alliance library) handles registration and assertion. Wrap it with a small route module; persist (user_id, credential_id, public_key).

Pair with the patterns in FastAPI + Pydantic v2 + SQLAlchemy 2.0 .

Django

Django’s batteries-included auth is still the right starting point. Add django-allauth for OIDC and django-passkeys (or django-fido) for WebAuthn:

# settings.py
INSTALLED_APPS = [
    "django.contrib.auth",
    "django.contrib.sites",
    "allauth", "allauth.account", "allauth.socialaccount",
    "allauth.socialaccount.providers.google",
    "passkeys",
]
AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    "allauth.account.auth_backends.AuthenticationBackend",
    "passkeys.backend.PasskeyModelBackend",
]

Django sessions live in your DB by default; switch to a cached backend for scale. See Django 5 Async for the async-friendly setup.

Hono on Bun (TypeScript)

import { Hono } from "hono";
import { getCookie, setCookie } from "hono/cookie";
import { OAuth2Client } from "google-auth-library";

const app = new Hono();

const google = new OAuth2Client({
  clientId: env.GOOGLE_CLIENT_ID,
  clientSecret: env.GOOGLE_CLIENT_SECRET,
  redirectUri: env.GOOGLE_REDIRECT_URI,
});

app.get("/auth/login", (c) => {
  const url = google.generateAuthUrl({
    access_type: "offline",
    scope: ["openid", "email", "profile"],
    code_challenge_method: "S256" as any,
  });
  return c.redirect(url);
});

app.get("/auth/callback", async (c) => {
  const code = c.req.query("code")!;
  const { tokens } = await google.getToken(code);
  const ticket = await google.verifyIdToken({
    idToken: tokens.id_token!,
    audience: env.GOOGLE_CLIENT_ID,
  });
  const payload = ticket.getPayload()!;

  const user = await db.upsertUser({ email: payload.email!, sub: payload.sub! });
  const sid = await db.createSession(user.id);

  setCookie(c, "sid", sid, {
    httpOnly: true,
    secure: true,
    sameSite: "Lax",
    maxAge: 30 * 24 * 60 * 60,
  });
  return c.redirect("/");
});

For passkeys: @simplewebauthn/server is the pragmatic choice. Same shape — the client uses navigator.credentials.create() / .get(); the server verifies.

See Modern TypeScript Backend with Hono on Bun for the surrounding stack.

Next.js — Auth.js v5

Auth.js (formerly NextAuth) is the right default for App Router projects:

// auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import Passkey from "next-auth/providers/passkey";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/lib/db";

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  providers: [Google, Passkey],
  session: { strategy: "database" },           // server sessions, not JWT
  experimental: { enableWebAuthn: true },
});

Server Components read the session directly:

// app/page.tsx
import { auth } from "@/auth";

export default async function Home() {
  const session = await auth();
  if (!session) return <SignInButton />;
  return <p>Hi {session.user.name}</p>;
}

session: { strategy: "database" } is important. The default in older Auth.js versions was JWT. In 2026, prefer database-backed sessions for the revocation guarantees.

See Next.js 15 Server Components in Production for the surrounding patterns.

A surprising amount of auth pain is cookie config:

Set-Cookie: sid=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000
  • HttpOnly — JavaScript can’t read it. Mitigates XSS-driven theft.
  • Secure — only sent over HTTPS.
  • SameSite=Lax — sent on top-level navigation but not cross-site fetches. Good default. Use Strict for higher security if you don’t need cross-site links into your app.
  • SameSite=None; Secure — only when you need the cookie on cross-origin requests. Always pair with CSRF tokens.

For SPAs talking to a separate API origin, SameSite=None; Secure + CSRF tokens (or double-submit cookies ) is the safe pattern.

CSRF in 2026

If you do anything mutating with cookies, you need CSRF protection. Defaults that protect you:

  • SameSite=Lax is the cheap baseline; covers most cases.
  • CSRF tokens for SameSite=None setups: server emits a per-session token; client echoes it as a header on POST/PUT/DELETE.
  • Origin/Referer check as a defense-in-depth on critical mutations.

For pure JSON APIs called from your own origin with SameSite=Lax cookies, you’re mostly safe; for forms and cross-origin clients, add explicit CSRF tokens.

Multi-factor and step-up auth

A passkey is a strong factor by itself. For high-risk actions (changing email, admin operations, financial transfers), prompt a step-up auth: re-prompt the user to authenticate even within an active session.

@app.post("/admin/transfer")
async def transfer(request: Request, payload: Transfer, user: User = Depends(current_user)):
    if not payload.step_up_token or not verify_step_up(payload.step_up_token, user):
        return JSONResponse({"error": "step_up_required"}, status_code=403)
    # ...

The client’s flow: catch the 403, prompt the user to authenticate again, retry with the fresh proof.

Common mistakes I keep seeing

1. Long-lived JWTs as sessions

A 30-day JWT is functionally a key to your service that you can’t revoke. Compromised? Tough. Use server sessions, or use very short JWTs (5–15 min) with refresh tokens.

2. Storing OAuth access tokens in localStorage

XSS reads localStorage. Use HTTP-only cookies. If you absolutely need the token in the browser, scope it tightly and rotate aggressively.

3. No rate limiting on login

A real-world attack vector. Rate-limit by IP and by user email. See Design a Rate Limiter .

4. Not setting Strict-Transport-Security

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

Without HSTS, a single TLS-stripping moment costs your session.

5. Trusting id_token without verifying

Always verify the OIDC id_token signature and the aud/iss claims. Libraries do this, but I’ve seen homemade flows skip it. Don’t.

6. Email enumeration

/login should return identical error messages for “user not found” vs “wrong password” — otherwise an attacker can probe which emails exist. Same for /forgot-password: respond with “if an account exists, you’ll get an email.”

7. Not handling email change

When a user changes email, do you re-verify? Do you log out other sessions? In a 2026 application, yes to both. The “I changed my email and now my account got taken over” flow is a classic incident.

A starter rubric

For a new product in 2026:

  • Primary auth: Passkeys + email magic-link fallback.
  • Federation: “Sign in with Google” via OIDC (and Apple if you have iOS users).
  • Session: Server-side, in Postgres or Redis, 30-day rolling expiry.
  • Cookie: HTTP-only, Secure, SameSite=Lax.
  • CSRF: SameSite + token header for SPAs across origins.
  • Step-up: For privileged actions, re-prompt.
  • Rate limit: On every auth endpoint.
  • Logging: Login, logout, password reset, suspicious activity. With IP + UA. Keep 90 days.
  • Provider: Auth.js for Next.js, Authlib + DIY for Python, allauth + django-passkeys for Django, framework-of-choice + simplewebauthn for Hono.

That’s the boring, correct answer.

When to outsource

Use a managed provider (Clerk, Auth0, WorkOS, Stytch, Kinde) if:

  • You need SAML / SSO for enterprise customers.
  • You don’t want to think about device fingerprinting, anomaly detection, or magic links.
  • The cost of getting auth wrong outweighs the SaaS bill.

For a typical B2B SaaS shipping in 2026, a managed provider is often the right call. They handle every edge case you’d otherwise discover painfully.

Read this next

If you want a working “FastAPI + Postgres + passkeys + Google OIDC + sessions” starter, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .