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
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 authorizationPrerequisites
- 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.
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. Addjtionly when you need token identity. - Rotate keys with unique
kidvalues. 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 standardJwtDiscoveryHttpOperations.jwksoperation so remote verifiers can discover rotation keys.- Stateless verification does not consult revocation. If immediate invalidation is required, issue
jti, persist revocations, 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 the JWT operations reference.