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:
| Credential | Job |
|---|---|
| Session cookie | Authenticates the browser to your application; opaque, HttpOnly, and backed by server state. |
| Access token | Authorizes 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
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:
| Concern | Alchemy v2 / Cloudflare choice |
|---|---|
| Issuer | Auth Worker with the private and public JWK bindings |
| Verifier | API Worker with only the public JWK binding |
| Private key | Config.redacted("ACCESS_TOKEN_PRIVATE_JWK"); beta.63 also keeps it out of Init output |
| Token state | None 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.
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. Addjtionly when you need token identity. JwtIssueralways writesexp: 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
kidvalues. 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/dandp/q/dp/dq/qi; ES256 uses EC P-256x/y/d; and EdDSA uses OKP Ed25519x/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 optionalalg,kid,use, andkey_opsmetadata must agree with the record. Jwks.document()exposes only public JWK material. Publish it with the standardJwtDiscoveryHttpOperations.jwksoperation 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 useJwtRevocation.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
introspectandrevoke; they do not mint tokens. See HTTP Operations.