effect-auth

Issue JWT access tokens

Mint short-lived bearer tokens from an authenticated application session.

Goal

Exchange an already authenticated application context for a short-lived JWT that another API can verify. The session cookie and JWT are different credentials:

CredentialJob
Session cookieAuthenticates the browser to your application; opaque, HttpOnly, and backed by server state.
Access tokenAuthorizes a caller to a specific API; sent explicitly as Authorization: Bearer … and valid until its short expiry.

Issuing a JWT does not sign a user in. Read Sessions first.

Flow

browser + session cookie
        |
        v
authenticated application endpoint
        |  userId + current app permissions
        v
     JwtIssuer ---- RS256 private key
        |
        +---- short-lived access token ----> API
                                             |
                              JwtVerifier + public key
                                             |
                                      app authorization

Prerequisites

  • A handler that has already validated the session and produced a trusted userId.
  • An RS256 key pair loaded from validated secret/configuration input. Never place the private JWK in source control.
  • Stable issuer and audience identifiers. Effect Auth does not read JWT environment variables or supply these values; see Configuration.

Minimal implementation

Build issuer and verifier services from one key set. In a split deployment, give the issuer both JWKs and give verifiers only the public JWK.

access-tokens.ts
import { Duration, Effect, Layer, Redacted } from "effect";
import {
  JwtIssuer,
  JwtKeyId,
  JwtKeysMemoryLive,
  JwtVerifier,
  JwtWebCryptoRs256SignatureLive,
  JwtFromSignatureLive,
  type JwtPrivateJwk,
  type JwtPublicJwk,
} from "@effect-auth/core/Jwt";

export const AccessTokenLive = (keys: {
  readonly privateJwk: JwtPrivateJwk;
  readonly publicJwk: JwtPublicJwk;
}) =>
  JwtFromSignatureLive.pipe(
    Layer.provide(JwtWebCryptoRs256SignatureLive()),
    Layer.provide(
      JwtKeysMemoryLive([
        {
          id: JwtKeyId("access-token-rs256-2026-07"),
          alg: "RS256",
          status: "active",
          ...keys,
        },
      ])
    )
  );

// Call only after session authentication. `userId` is trusted server context.
export const issueAccessToken = (userId: string) =>
  JwtIssuer.use((jwt) =>
    jwt.issue({
      alg: "RS256",
      issuer: "https://auth.example.com",
      subject: userId,
      audience: "projects-api",
      expiresIn: Duration.minutes(10),
      claims: { scope: "projects:read" },
    })
  );

export const verifyAccessToken = (rawToken: string) =>
  JwtVerifier.use((jwt) =>
    jwt.verify({
      token: Redacted.make(rawToken),
      issuer: "https://auth.example.com",
      audience: "projects-api",
      clockTolerance: Duration.seconds(30),
    })
  );

Return Redacted.value(issued.token) to the authenticated caller. Do not put it in a cookie or log it. At the API, reject valid: false; for valid: true, treat sub and scope only as signed input to application-owned authorization. Your application decides what scopes mean and whether the user still has access. Follow Protect an API endpoint for bearer extraction and endpoint integration.

Test it

Provide AccessTokenLive(testKeys) to a test program, issue a token, then verify it:

const program = Effect.gen(function* () {
  const issued = yield* issueAccessToken("user-123");
  const result = yield* verifyAccessToken(Redacted.value(issued.token));

  if (!result.valid) throw new Error(result.reason);
  return result.claims;
}).pipe(Effect.provide(AccessTokenLive(testKeys)));

Also test a changed signature, wrong issuer, wrong audience, and verification after expiry. Assert authorization separately; a valid signature is not permission by itself.

Production notes

  • Keep TTLs short and always verify signature, iss, aud, and time claims. Add jti only when you need token identity.
  • Rotate keys with unique kid values. Keep retired public keys available until every token they signed has expired; disable compromised keys.
  • Jwks.document() exposes only public JWK material. Publish it with the standard JwtDiscoveryHttpOperations.jwks operation so remote verifiers can discover rotation keys.
  • Stateless verification does not consult revocation. If immediate invalidation is required, issue jti, persist revocations, and use JwtRevocation.introspect (or the standard JWT introspection operation) on the authorization path. That adds storage and availability costs; short TTLs are usually simpler.
  • The standard JWT HTTP operations are introspect and revoke; they do not mint tokens. See the JWT operations reference.

On this page