Skip to content
intermediate Phase 10 · Authentication and Sessions

OAuth and Credentials Providers

Delegate sign-in to providers or verify passwords yourself.

50m
0 problems
Topic Progress 0%

Wiring Auth.js v5: Four Exports, One Route Handler, and Secrets That Never Enter the Repo

A product manager files a ticket: "Add Sign in with GitHub by Friday." The immediate question is not which button to render — it is whether your application will ever see a password. If the answer is yes, you inherit storage, hashing, reset emails, rate limiting, and breach liability. If the answer is no, an identity provider holds all of that and your job shrinks to checking an assertion about who someone is. An OAuth redirect flow delegates password handling to the identity provider, and that single decision is the whole design.

The delegation is real, not cosmetic. When a user types their password into github.com, the provider stores a salted hash, applies its own throttling, sends its own reset emails, and can offer MFA without you shipping a TOTP flow. What returns to your server is an authorization code, and after the exchange, profile claims: a stable subject id, an email address, and sometimes a verified flag. What you give up is availability and policy: if the provider is down or revokes your redirect URI, sign-in stops, and you are trusting their phishing resistance instead of your own.

Auth.js v5 — published under the next-auth package — makes the wiring small. One call at the project root produces every function the rest of the app imports:

// auth.ts — project root, imported only from server code
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "database" },
  providers: [
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID,
      clientSecret: process.env.AUTH_GITHUB_SECRET,
    }),
  ],
  pages: { signIn: "/login" },
  callbacks: {
    // domain allow-list: return false => AccessDenied at the callback
    signIn: ({ profile }) => profile?.email?.endsWith("@example.com") ?? false,
  },
})

handlers is an HTTP contract, and the App Router expects it mounted behind one catch-all route. Every path the library owns — /api/auth/signin, /api/auth/callback/github, /api/auth/signout, /api/auth/session — lands there:

// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers

signIn and signOut are server-side entry points. Trigger them from a server action or a form posting to a route handler; a client component never needs provider credentials to start the flow:

"use server"
import { signIn, signOut } from "@/auth"

export async function continueWithGitHub() {
  await signIn("github", { redirectTo: "/dashboard" })
}

export async function logout() {
  await signOut({ redirectTo: "/" })
}

Everything else is configuration, and provider configuration lives in environment variables, never in committed code:

Variable What it is Where it must live
AUTH_SECRET Signs and encrypts session cookies and JWTs server env only, 32+ random bytes
AUTH_GITHUB_ID Public OAuth client id issued by the provider server env
AUTH_GITHUB_SECRET OAuth client secret for the code exchange server env, never committed
AUTH_URL / AUTH_TRUST_HOST Canonical base URL behind a proxy or container deploy env

Generate the signing secret with openssl rand -base64 32, keep it in .env.local, and check that .env* is in .gitignore before your first commit — the classic leak is an env file force-added to an early commit, which means the value must be treated as public and rotated. Never prefix a secret with NEXT_PUBLIC_: that build-time substitution inlines the value into the JavaScript shipped to the browser, and nothing downstream pulls it back out. Client ids are visible in the HTML regardless; only the client secret and the signing key are yours to guard.

Environments differ, which is the second reason config stays out of the repository. Localhost, staging, and production each register their own callback URL (https://yourapp.example/api/auth/callback/github) at the provider, each gets its own secret, and a committed config guarantees one of them is wrong. Rotate at the provider whenever an employee with dashboard access leaves.

With the session cookie now issued, the natural next question is how your pages read it: Session Reads in Server Components continues from here, and the structured-data groundwork from JSON-LD Structured Data is what this topic assumes is already solid.

From Click to Cookie: The Authorization-Code Exchange You Will Be Asked to Draw

"Walk me through what happens when a user clicks Sign in with GitHub." Interviewers ask this because the answer exposes whether you have actually shipped OAuth or only copied a tutorial. The flow has five legs, and each one fails differently in production.

  1. Your app redirects to the provider. signIn("github") sends the browser to an authorization endpoint carrying your client id, a redirect_uri, requested scope, and a random state:
https://github.com/login/oauth/authorize
  ?client_id=Iv1.8f7a2b
  &redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fcallback%2Fgithub
  &scope=read%3Auser+user%3Aemail
  &state=Xk9pQ2r7tL
  1. The user authenticates at the provider. Their password never touches your domain. If they are already signed in to GitHub, this leg is one click — the real reason OAuth converts better than a home-grown login form.
  2. The provider redirects back with a code. The browser lands on your catch-all route: /api/auth/callback/github?code=1f9d43&state=Xk9pQ2r7tL. The code is short-lived (minutes), single-use, and useless without your client secret.
  3. Your server exchanges the code for tokens. Auth.js performs the POST server-side, so the client secret never reaches the browser:
// what the callback does with the code, simplified
const res = await fetch("https://github.com/login/oauth/access_token", {
  method: "POST",
  headers: { "Content-Type": "application/json", Accept: "application/json" },
  body: JSON.stringify({
    client_id: process.env.AUTH_GITHUB_ID,
    client_secret: process.env.AUTH_GITHUB_SECRET,
    code: "1f9d43",
    redirect_uri: "https://yourapp.example/api/auth/callback/github",
  }),
})
const { access_token } = await res.json()   // then fetch /user, map to your User row
  1. Your server persists the identity and sets the cookie. The adapter upserts User and Account rows (storing the provider's subject id so re-sign-ins match the same row), creates a session record under the database strategy, and returns Set-Cookie. The browser now carries the session on every request.

state exists to kill login CSRF: an attacker who gets a victim's browser to complete attacker's sign-in gets rejected when the returned state does not match the one your app stored in a cookie. The authorization code itself is protected by the exchange — the callback route exchanges a short-lived code for tokens and a user record, and only a server holding the client secret can do that exchange.

What you persist matters as much as the flow. The adapter writes the provider's access_token into the Account row, which makes that row sensitive data: it can often read or act on the user's provider account, so treat it like a credential (encrypt at rest, never return it from an API, delete it on unlink). And because the provider's subject id — github|12345 — is the stable key, link accounts on that, never on email: addresses change, get recycled, and differ across providers, and keying on them merges two real people into one account.

Where teams get hurt is configuration, not cryptography:

Failure Symptom Cause
redirect_uri mismatch Provider rejects before login Callback URL differs by www, protocol, or port
state mismatch / OAuthCallback Error page after approval Cookie holding state lost on a redirect or a different host
AccessDenied Sign-in refused for some users Your signIn callback returned false
User row created twice Split accounts Email differs across providers, so linking key is wrong

The lost-cookie case deserves attention because your own middleware can cause it: if middleware matches /api/auth/callback/* and redirects an unauthenticated visitor to /login, the inbound code and state are dropped and the flow dies with a provider-side error. Protecting Routes in Middleware shows the matcher that keeps this route clear.

Finally, decide what you believe from the provider. Google's profile includes email_verified: true; other providers hand back any address the user typed. Do not auto-trust an unverified email as a unique login key — two users could claim the same address, and an attacker can register yours first. Verify it yourself, or restrict which providers may assert an address, before the profile callback maps claims onto your user table.

Verifying the Password Yourself: The Credentials Provider Without the Footguns

Sometimes delegation is not on the table: you are migrating an existing user table, the client mandates email and password, or the app must authenticate with no network at all. Auth.js supports this through the Credentials provider, and it moves every risk you deferred earlier onto your code.

Write authorize as if it is public, because it is reachable by anyone who can POST to /api/auth/callback/credentials:

import Credentials from "next-auth/providers/credentials"
import { z } from "zod"
import bcrypt from "bcryptjs"

const schema = z.object({
  email: z.string().trim().toLowerCase().email(),
  password: z.string().min(8).max(128),
})
const DUMMY_HASH = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"

export const { handlers, auth, signIn, signOut } = NextAuth({
  session: { strategy: "jwt" },   // required with Credentials — see below
  providers: [
    Credentials({
      credentials: { email: {}, password: {} },
      authorize: async (raw) => {
        const parsed = schema.safeParse(raw)
        if (!parsed.success) return null
        const { email, password } = parsed.data

        const user = await prisma.user.findUnique({ where: { email } })
        // Always run a comparison, even when no user exists: identical work,
        // identical timing, so response time cannot confirm an account.
        const ok = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH)
        if (!ok || !user || user.lockedUntil > new Date()) return null

        return { id: user.id, email: user.email, role: user.role }
      },
    }),
  ],
})

Three things in that block are the job. Returning null produces the library's generic credentials error, so the browser learns only "those credentials did not work" — never whether the email exists. The dummy hash equalizes timing so enumeration through response latency gets nothing. And input is validated before it touches the database, because authorize receives attacker-controlled objects, not typed form values.

A hard constraint follows: Auth.js only issues database sessions for OAuth-style providers; Credentials requires session.strategy: "jwt". Ask for a database session and sign-in fails with an UnsupportedStrategy error. The consequence is that the claims you return from authorize are serialized into the session cookie itself, which creates the most common credentials bug in the wild: spreading the whole Prisma user into the token.

callbacks: {
  jwt: ({ token, user }) => {
    if (user) token.role = user.role      // carry only what you need
    return token                          // NEVER: return { ...token, ...user }
  },
}

Spreading user writes passwordHash into a base64 payload that the browser stores and the user can decode with one line in DevTools. Claims in a JWT are readable by design; only the signature is secret.

The trade-off against the provider flow is stark enough to state in an interview:

Concern OAuth provider Credentials (yours)
Password storage Provider's salted hash Your table, your breach
Reset emails, MFA, lockout Largely handled You build all three
Account enumeration Provider's problem Your timing and copy to get right
Sign-up conversion Higher (one click) Lower (form + password rules)
Works offline / with an existing user DB No Yes

Because the endpoint is yours, throttle it: rate-limit per IP and per account on /api/auth/callback/credentials, back off after repeated failures, and log every rejection with the email involved so a credential-stuffing run is visible the same day. Hash with bcrypt or Argon2 and a unique salt — never encrypt, because a reversible password is one misconfigured endpoint away from being handed out. The checks you just built are also the raw material for Role-Based Access Control, where knowing who someone is turns into deciding what they may do.

Quiz

1. You define the GitHub OAuth client id and client secret in NEXT_PUBLIC_ environment variables so "both sides can use them". What actually happens?

Question 1 options

2. A user approves the app at GitHub but lands on an error page reporting OAuthCallback. Your middleware redirects every unauthenticated request to /login and its matcher includes /api. What went wrong?

Question 2 options

3. In the authorization-code flow, what is exchanged for what, and by whom?

Question 3 options

4. Which statement about the Credentials provider in Auth.js v5 is correct?

Question 4 options

Flashcards

Question

Why delegate sign-in to an OAuth provider instead of storing passwords yourself?

Answer

The redirect flow hands hashing, reset emails, MFA, throttling, and breach liability to the identity provider; your server only verifies a code exchange and stores profile claims. The cost is dependence on their availability and phishing resistance, plus a real decision about which claims — especially email — you trust.

Question

What happens on /api/auth/callback/<provider> after the user approves access?

Answer

The catch-all route receives a single-use, minutes-lived authorization code plus state. Auth.js exchanges the code server-side using the client secret for an access token, fetches the profile, upserts User and Account rows through the adapter, and returns the session Set-Cookie. A state mismatch aborts the flow as CSRF protection.

Question

Which environment variables does a GitHub sign-in need, and which must never reach the browser?

Answer

AUTH_GITHUB_ID and AUTH_GITHUB_SECRET (registered per environment at the provider) plus AUTH_SECRET, which signs and encrypts the session cookie. AUTH_GITHUB_SECRET and AUTH_SECRET stay in server-only env — a NEXT_PUBLIC_ prefix inlines them into the client bundle at build time.

Question

Why does Auth.js reject database sessions when the Credentials provider is used?

Answer

Credentials sign-in returns no OAuth profile and creates no adapter-backed user path to hang a database session on, so Auth.js requires session.strategy: "jwt". Requesting a database session fails with an UnsupportedStrategy error, which is why credentials claims are serialized into the cookie instead.

Question

How do you keep a credentials login from confirming whether an email is registered?

Answer

Return null for every failure and answer with one generic "Invalid email or password" response. Run bcrypt against a dummy hash when no user exists so the work and timing match, validate input before touching the database, and rate-limit the endpoint so enumeration by repetition is also cut off.

Revision Notes

Key Takeaways

  • 1. An OAuth redirect flow delegates password handling to the identity provider — you verify a code exchange, they own hashing, resets, and MFA
  • 2. The callback exchanges a short-lived, single-use code for tokens server-side, so the client secret and the code never coexist in the browser
  • 3. Provider config lives in environment variables registered per environment; a NEXT_PUBLIC_ prefix inlines the value into the shipped bundle
  • 4. Middleware that redirects unauthenticated requests must exclude /api/auth/* or it destroys the callback's code and state
  • 5. Credentials forces the JWT session strategy, which makes what you return from authorize() and the jwt callback directly readable in the cookie

Interview Tips

  • • "Walk me through an OAuth code exchange" — draw the five legs, name state as CSRF protection, and say plainly that the exchange happens server-side because only your server holds the client secret
  • • "Where would you keep the OAuth client secret?" — server env only, one set per environment, rotate on leak; explaining why NEXT_PUBLIC_ is a build-time inliner (not a runtime guard) signals you have shipped secrets before
  • • "What does the signIn callback decide?" — it is the last gate before a session exists: domain allow-lists, provider restrictions, and blocking unverified emails from becoming accounts
  • • "Compare OAuth sign-in with a credentials login" — walk the table: storage liability, enumeration, resets, MFA, conversion rate, and offline support; then say which one you would pick for this product and why

Cheat Sheet

OAuth and Credentials Providers

Auth.js v5 wiring (one call, four exports):

// auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "database" },
  providers: [GitHub({ clientId: process.env.AUTH_GITHUB_ID,
                      clientSecret: process.env.AUTH_GITHUB_SECRET })],
  pages: { signIn: "/login" },
})

// app/api/auth/[...nextauth]/route.ts
export const { GET, POST } = handlers

Authorization-code flow:

Leg What happens What must not leak
1. Redirect Browser goes to provider authorize URL with client_id, redirect_uri, scope, state Nothing secret — id is public
2. Authenticate User types the password at the provider Your domain never sees it
3. Callback Provider redirects to /api/auth/callback/<p>?code=...&state=... Middleware must not redirect this path
4. Exchange Server trades code + client secret for tokens, fetches profile Client secret stays server-side
5. Session Adapter upserts user/account rows, Set-Cookie returned Cookie is HttpOnly + Secure

Environment: AUTH_SECRET (32 random bytes, signs cookies), AUTH_GITHUB_ID, AUTH_GITHUB_SECRET, AUTH_URL/AUTH_TRUST_HOST. Never NEXT_PUBLIC_ a secret.

Credentials provider: validate with zod → bcrypt.compare against a dummy hash when the user is missing → return null on any failure → JWT strategy is mandatory → never spread the whole user into the jwt callback.

OAuth vs credentials: provider handles hashing/resets/MFA/enumeration at the cost of uptime and policy dependence; credentials work offline and with an existing user table but hand you every one of those problems.

Continue Learning