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

Exchange an application session for API credentials

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 supplied explicitly at the application boundary; Effect Auth does not read JWT environment variables or choose these values.

For Cloudflare deployments, keep the runtime and infrastructure boundary small:

ConcernAlchemy v2 / Cloudflare choice
IssuerAuth Worker with the private and public JWK bindings
VerifierAPI Worker with only the public JWK binding
Private keyConfig.redacted("ACCESS_TOKEN_PRIVATE_JWK"); beta.63 also keeps it out of Init output
Token stateNone for short-lived JWTs; add D1 only when using revocation

When immediate revocation is required, apply the complete migration stream and provide one focused durable JwtRevocationStore. SQLite/Node, Cloudflare D1, and PostgreSQL applications can use makeDrizzleSqliteJwtRevocationStore, makeDrizzleD1JwtRevocationStore, or makeDrizzlePostgresJwtRevocationStore, respectively. The D1 and PostgreSQL modules also export layer for an application-owned database service and layerNoDeps for an already acquired database.

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,
  JwtIssuerFromSignatureConfiguredLive,
  JwtVerifierFromSignatureConfiguredLive,
  JwtWebCryptoRs256SignatureLive,
  type JwtPrivateJwk,
  type JwtPublicJwk,
} from "@effect-auth/core/Jwt";

const AccessTokenKeysLive = (keys: {
  readonly publicJwk: JwtPublicJwk;
  readonly privateJwk?: JwtPrivateJwk;
}) =>
  JwtKeysMemoryLive([
    {
      id: JwtKeyId("access-token-rs256-2026-07"),
      alg: "RS256",
      status: "active",
      ...keys,
    },
  ]);

export const AccessTokenIssuerLive = (keys: {
  readonly privateJwk: JwtPrivateJwk;
  readonly publicJwk: JwtPublicJwk;
}) =>
  JwtIssuerFromSignatureConfiguredLive({
    defaultLifetime: Duration.minutes(10),
    maximumLifetime: Duration.hours(1),
    issuer: "https://auth.example.com",
    audience: "projects-api",
  }).pipe(
    Layer.provide(JwtWebCryptoRs256SignatureLive()),
    Layer.provide(AccessTokenKeysLive(keys))
  );

export const AccessTokenVerifierLive = (publicJwk: JwtPublicJwk) =>
  JwtVerifierFromSignatureConfiguredLive({
    issuer: "https://auth.example.com",
    audience: "projects-api",
    clockTolerance: Duration.seconds(30),
  }).pipe(
    Layer.provide(JwtWebCryptoRs256SignatureLive()),
    Layer.provide(AccessTokenKeysLive({ publicJwk }))
  );

export const AccessTokenLive = (keys: {
  readonly privateJwk: JwtPrivateJwk;
  readonly publicJwk: JwtPublicJwk;
}) =>
  Layer.mergeAll(
    AccessTokenIssuerLive(keys),
    AccessTokenVerifierLive(keys.publicJwk)
  );

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

export const verifyAccessToken = (rawToken: string) =>
  JwtVerifier.use((jwt) =>
    jwt.verify({
      token: Redacted.make(rawToken),
    })
  );

Return Redacted.value(issued.token) to the authenticated caller; never put it in a cookie or log it. At the API, reject valid: false as an invalid credential and map verifier/key failures separately. For valid: true, treat sub and scope only as signed input to application-owned authorization. Follow Protect an API endpoint for bearer extraction and endpoint integration.

JwtKeysMemoryLive, JwtIssuerFromSignatureConfiguredLive, and JwtVerifierFromSignatureConfiguredLive validate while their Layers build and can fail with SecurityConfigurationError. Key IDs must be unique trimmed strings of at most 128 characters. HS256 secrets must contain 32 through 1024 bytes; asymmetric JWKs are validated structurally instead of being subjected to the symmetric byte rule. Configured issuer/audience values and clock tolerance are bounded before verification work.

Configured issuer and audience values are authoritative. Calls may repeat the same values, but conflicting per-call overrides fail before signing or signature verification. Default and maximum lifetimes must use whole milliseconds. Maintained verification requires a finite safe-integer exp; malformed exp, iat, or nbf NumericDate values are rejected.

Test it

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

const program = Effect.gen(function* () {
  const issued = yield* issueAccessToken("acme-project-member-42");
  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.
  • JwtIssuer always writes exp: omission uses the validated 15-minute default. Per-call lifetimes must be positive and cannot exceed the configured maximum or the library ceiling of 24 hours.
  • Rotate keys with unique kid values. Keep retired public keys available until every token they signed has expired; disable compromised keys.
  • Keep key records algorithm-coherent: HS256 uses only a symmetric secret; RS256 private JWKs require RSA n/e/d and p/q/dp/dq/qi; ES256 uses EC P-256 x/y/d; and EdDSA uses OKP Ed25519 x/d. When separate public and private JWKs are configured, their public components must match exactly. Public JWKs must not contain private or symmetric members, and optional alg, kid, use, and key_ops metadata must agree with the record.
  • 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, provide a focused durable direct revocation store, 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 HTTP Operations.

On this page