HyperSaaS
FrontendAuthentication

Token Flow

Signing in, the code step, refreshing tokens, changing the password and signing out.

Token Lifecycle

Sign in (email + password, then a code if two-factor is on)
  │
  ▼
allauth issues {access_token, refresh_token}
  │
  ▼
Auth.js keeps both in its encrypted cookie
  │
  ▼
Server code sends the access token: Authorization: Bearer <token>
  │
  ├── Token valid → Request succeeds
  │
  └── Token expiring (< 60s left) → Refresh
      │
      ▼
      POST /_allauth/app/v1/tokens/refresh  →  {access_token, refresh_token}
      │
      ├── Success → Keep both new tokens, continue
      └── Failure → Session ends → /login
TokenLifetime
Access15 minutes
Refresh14 days, and each one works once

Every token belongs to a sign-in on the backend, checked on every request. Signing out, changing the password, or a password reset ends a sign-in's tokens straight away.

Refreshing saves the new tokens to the cookie only when it happens in the proxy, which runs on the app's pages and API routes and passes the new cookie on to the request it's handling.

Server-Side Token Access

Server components, route handlers and server actions get the access token with getAccessToken():

// lib/auth/utils.ts
export async function getAccessToken(): Promise<string | undefined> {
  const session = await auth();
  return session?.accessToken;
}

Most code doesn't call it directly: backendFetch, relayToBackend (lib/api/backend.ts) and relayToAllauth (lib/auth/allauth.ts) send it for you.

export default async function Page() {
  const workspaces = await backendJson<Workspace[]>("/api/workspaces/");
  // ...
}

The browser never gets the token. Client components call the app's own /api/... routes, and the session cookie does the rest.

Login Flow

The form calls server actions in lib/auth/actions.ts:

const result = await loginAction({ email, password });
if (result.success) router.push(returnUrl);
else if (result.step === "code") showCodeStep();  // two-factor is on
else toast.error(result.error);

// The code step
const result = await verifyCodeAction(code);      // a code from the app, or a recovery code

loginAction calls allauth's auth/login. With two-factor on, allauth answers with a session token for the code step, which waits in an httpOnly cookie for five minutes. verifyCodeAction sends it with the code to auth/2fa/authenticate. Either way, the tokens go to signIn("credentials", tokens), which checks them with /auth/users/me/ and starts the session.

The actions pass the browser's IP and User-Agent on to allauth (X-Client-IP), for its rate limits and the security emails it sends.

Changing the Password

changePasswordAction calls allauth's account/password/change. Other devices are signed out. The sign-in that made the change gets a new key on the backend, which ends its tokens too, so the action trades allauth's new session token for new tokens (/auth/tokens/from-session/) and starts the Auth.js session again with them. You stay signed in.

Signing Out

signOut() from anywhere (the user menu, a failed refresh, deleting the account) also ends the sign-in on the backend, through Auth.js's events.signOut.

Registration Flow

Registration goes directly to the Django backend's djoser endpoints (no Auth.js involvement):

1. POST /auth/users/  →  Create user account
2. Backend sends activation email
3. User clicks activation link → /user-activation/[uid]/[token]
4. POST /auth/users/activation/  →  Activate account
5. User can now sign in

Client-Side Session Access

Client components read who's signed in with useSession:

"use client";
import { useSession } from "next-auth/react";

function UserMenu() {
  const { data: session, status } = useSession();

  if (status === "loading") return <Skeleton />;
  if (!session) return <LoginButton />;

  return <span>{session.user.email}</span>;
}

On this page