# Authentication basics

> 

A minimal auth flow: sign a token on login, verify it on every protected request, reject expired or tampered tokens automatically.

This example uses `HS256` (symmetric) — the simplest and fastest path when the same service signs and verifies. If you're building a system where third parties need to verify tokens without being able to sign them, see [JWKS endpoint](/examples/jwks-endpoint) instead.

## Setup

```ts [secrets.ts]
import { generateJWK } from "unjwt/jwk";

// Generate once, persist. Treat like any other secret.
const signingKey = await generateJWK("HS256", { kid: "primary-2025" });
// { kty: "oct", k: "...", alg: "HS256", kid: "primary-2025" }
```

In production, load this key from a secrets manager (or at minimum, `process.env`). Do **not** generate a fresh key on every boot — all existing tokens would instantly become invalid.

```ts [env-loading.ts]
import type { JWK_oct } from "unjwt/jwk";
import { isSymmetricJWK } from "unjwt/utils";

const parsed = JSON.parse(process.env.JWT_SIGNING_KEY!);
if (!isSymmetricJWK(parsed)) {
  throw new Error("JWT_SIGNING_KEY must be a symmetric JWK");
}
const signingKey: JWK_oct = parsed;
```

## Issue a token (login)

```ts [login.ts]
import { sign } from "unjwt/jws";

async function issueAccessToken(userId: string, roles: string[]) {
  return sign({ sub: userId, roles }, signingKey, {
    expiresIn: "15m",
    protectedHeader: {
      typ: "access+jwt",
    },
  });
}
```

What unjwt adds to the payload automatically:

- `iat` — current time.
- `exp` — `iat + 900` (15 minutes from `expiresIn`).
- `kid` — copied from `signingKey.kid` into the header.

What your code controls:

- `sub` — subject, typically the user id.
- `roles` / `scopes` / whatever else your app needs.
- `typ: "access+jwt"` — a distinct type so a refresh token from the same app can't be mistaken for an access token ([RFC 8725 §3.11](https://www.rfc-editor.org/rfc/rfc8725#section-3.11) recommendation).

## Verify on a protected route

```ts [middleware.ts]
import { verify, type JWSVerifyResult } from "unjwt/jws";

async function requireAuth(
  request: Request,
): Promise<JWSVerifyResult<{ sub: string; roles: string[] }>> {
  const authHeader = request.headers.get("authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    throw new Response("Unauthorized", { status: 401 });
  }
  const token = authHeader.slice("Bearer ".length);

  try {
    return await verify<{ sub: string; roles: string[] }>(token, signingKey, {
      audience: "my-api",
      issuer: "my-app",
      typ: "access+jwt", // must match what we set on issue
      maxTokenAge: "15m", // defense-in-depth on top of exp
    });
  } catch (error) {
    throw new Response(`Invalid token: ${(error as Error).message}`, { status: 401 });
  }
}
```

Then in a route:

```ts [route.ts]
async function getProfile(request: Request) {
  const { payload } = await requireAuth(request);
  return Response.json({ userId: payload.sub, roles: payload.roles });
}
```

## What's checked automatically

When you call `verify()` with a payload that's a JSON object, unjwt runs these checks **without needing you to opt in**:

<table>
<thead>
  <tr>
    <th>
      Check
    </th>
    
    <th>
      What it rejects
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Signature
    </td>
    
    <td>
      Token was tampered with, or signed by the wrong key.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        alg
      </code>
      
       allowlist
    </td>
    
    <td>
      Wrong algorithm — derived from <code>
        signingKey.alg
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        exp
      </code>
    </td>
    
    <td>
      Token is past its expiration.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        nbf
      </code>
    </td>
    
    <td>
      Token is used before its "not before" time.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        iat
      </code>
    </td>
    
    <td>
      Not a finite number (if present).
    </td>
  </tr>
</tbody>
</table>

These are the checks that protect you from the classic JWT pitfalls (`alg: none` attack, algorithm confusion, expired-token reuse). You don't need to pass `algorithms` explicitly because `signingKey.alg` is set.

## Adding `issuer` / `audience`

These are defence-in-depth. They catch bugs where the same key is accidentally reused across services:

<CodeGroup>

```ts [on-issue.ts]
await sign({ sub: userId, roles, iss: "my-app", aud: "my-api" }, signingKey, {
  expiresIn: "15m",
  protectedHeader: { typ: "access+jwt" },
});
```

```ts [on-verify.ts]
await verify(token, signingKey, {
  issuer: "my-app",
  audience: "my-api",
});
```

</CodeGroup>

Now a token signed for `my-api` can't verify as a token for `admin-api`, even if both use the same signing key during a shared-infrastructure transition.

## Next steps

- **Third parties need to verify your tokens?** Switch to an asymmetric algorithm (e.g. `ES256`) and publish a [JWKS endpoint →](/examples/jwks-endpoint).
- **Want automatic session handling?** Use the [H3 session adapters →](/adapters) — cookie-based sessions with built-in refresh hooks.
- **Need long sessions without long-lived tokens?** Go to [Refresh token pattern →](/examples/refresh-token-pattern).

## See also

- [JWS: verifying](/jwt/jws/verifying)
- [JWS: signing](/jwt/jws/signing)
- [Core concepts](/getting-started/core-concepts)
