Skip to main content
Version: Canary

Signed Tokens

A signed token is a short, URL-safe string that carries a few claims, expires, and provably came from your application. Your api side hands it out, it travels through an untrusted place (a query string, an email, a third-party redirect), and when it comes back you can trust what's inside it without having stored anything.

Typical uses:

  • OAuth state. The callback from Google or GitHub is a plain function receiving a redirect, with no session to check a nonce against. A signed token in state carries the nonce and tells the callback which user or organization started the flow.
  • Email links. Confirmation, password-set, magic-login, and unsubscribe links all need "a URL that provably came from us, says who it is for, and stops working after a while."
  • Capability tokens. A token that lets a client do one specific thing for a few minutes, such as upload a file into one specific bucket.

@cedarjs/api exports two functions for this, createSignedToken and verifySignedToken, plus a SignedTokenError that verification throws whenever a token cannot be trusted.

Setup

Tokens are signed with a secret. Generate one and put it in your api side's .env file as SIGNED_TOKEN_SECRET:

yarn cedar generate secret
.env
SIGNED_TOKEN_SECRET=...the generated value...

Do not check this file into version control, and use a different value in each environment. Use a separate secret from SESSION_SECRET so the two can be rotated independently.

Both functions also accept a secret option, which takes precedence over the environment variable. There is no built-in fallback secret: if neither is set, both functions throw a SignedTokenError with code MISSING_SECRET rather than signing with a guessable value.

Creating a token

import { createSignedToken } from '@cedarjs/api'

const state = createSignedToken({
payload: { organizationId, userId },
purpose: 'google-oauth-state',
expiresIn: '10m',
})
OptionDescription
payloadThe claims to carry. Any JSON-serializable object. Values that are not JSON, such as a Date, come back as whatever JSON.stringify turns them into.
purposeRequired. What the token is for, like 'google-oauth-state' or 'email-confirmation'. Verification only succeeds with the same purpose, so a token minted for one flow cannot be replayed in another. Pick a distinct string per flow.
expiresInRequired. How long the token stays valid. A number is a count of seconds; a string is a duration such as '10m', '2h', or '7 days'. Keep it as short as the flow allows.
secretOptional. Overrides SIGNED_TOKEN_SECRET.

The result is a standard JSON Web Token signed with HS256, so it is URL-safe as-is and can be inspected with any JWT debugger. The payload is not encrypted, only signed: anyone holding the token can read the claims, they just cannot change them. Don't put secrets in the payload.

Verifying a token

import { verifySignedToken } from '@cedarjs/api'

const { organizationId, userId } = verifySignedToken<{
organizationId: string
userId: string
}>(event.queryStringParameters?.state, { purpose: 'google-oauth-state' })

verifySignedToken returns the JSON-deserialized payload, the same shape it was passed to createSignedToken. The type parameter tells TypeScript what shape to expect; the framework cannot check it at runtime, so treat it the way you would treat any value your own code serialized earlier.

Anything that stops the token from being trusted throws a SignedTokenError. It never returns null or false, so forgetting to check the result cannot let a bad token through. The token argument accepts undefined and null on purpose: pass the raw query-string or header value straight in, and a missing token is a verification failure like any other.

OptionDescription
purposeRequired. Must match the purpose the token was created with.
secretOptional. Overrides SIGNED_TOKEN_SECRET.

Handling failures

SignedTokenError has a code property so you can tell the cases apart, for instance to show "this link has expired, request a new one" instead of a generic error:

CodeMeaning
MISSING_TOKENThe token argument was undefined, null, or an empty string.
EXPIREDThe token was valid but its expiresIn has passed.
INVALIDThe token is malformed, was signed with a different secret, has been tampered with, or was not created by Cedar.
PURPOSE_MISMATCHThe token was created for a different purpose. The message names both purposes.
MISSING_PURPOSEpurpose was not passed or was empty. This is a programming error, not a bad token.
MISSING_SECRETNo secret option and no SIGNED_TOKEN_SECRET environment variable. This is a configuration error, not a bad token.
SIGN_FAILEDThrown by createSignedToken when the token could not be produced, such as an unparseable expiresIn.
import { SignedTokenError, verifySignedToken } from '@cedarjs/api'

try {
const { userId } = verifySignedToken<{ userId: string }>(token, {
purpose: 'email-confirmation',
})
await confirmEmail(userId)
} catch (e) {
if (e instanceof SignedTokenError && e.code === 'EXPIRED') {
return { statusCode: 410, body: 'This link has expired.' }
}

throw e
}

Only EXPIRED is usually worth a specific message to the user. The other codes are either a tampered or foreign token, which deserves a generic rejection, or a mistake in your own code or configuration, which should surface as an error during development.

Example: OAuth state without a session store

A function that starts an OAuth flow with a third-party calendar API puts a signed token in the state parameter. The callback verifies it and learns which organization to attach the integration to from the token itself, rather than from any ambient context.

The signature proves the token came from your app, but not that it came back through the same browser that started the flow. Without that binding an attacker could start a flow, hand the authorization URL to a victim, and have the victim's provider account linked to the attacker's organization. So the example also generates a random nonce, stores it in a short-lived cookie, and requires the callback's cookie to match the nonce inside the signed state. Recording each nonce in a table with a unique key makes the callback single-use: the second callback carrying the same state fails the insert and is refused.

The table is one model in schema.prisma:

model OAuthNonce {
nonce String @id
createdAt DateTime @default(now())
}

A row is never needed after the nonce's ten-minute lifetime, so a periodic deleteMany on createdAt keeps the table small. Starting a second attempt in the same browser overwrites the cookie, so only the most recent attempt can complete; that is the intended behavior for a flow the user drives one step at a time.

api/src/functions/calendarConnect.ts
import { randomBytes } from 'node:crypto'

import type { APIGatewayProxyEvent } from 'aws-lambda'

import { createSignedToken } from '@cedarjs/api'
import { authDecoder } from '@cedarjs/auth-dbauth-api'
import { context } from '@cedarjs/context'
import { useRequireAuth } from '@cedarjs/graphql-server'

import { getCurrentUser, requireAuth } from 'src/lib/auth'

const calendarConnect = async (_event: APIGatewayProxyEvent) => {
// `useRequireAuth` below populates `context.currentUser` for this function
requireAuth()

const nonce = randomBytes(16).toString('base64url')

const state = createSignedToken({
payload: {
nonce,
organizationId: context.currentUser.organizationId,
userId: context.currentUser.id,
},
purpose: 'calendar-oauth-state',
expiresIn: '10m',
})

const url = new URL('https://accounts.google.com/o/oauth2/v2/auth')
url.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID)
url.searchParams.set(
'redirect_uri',
`${process.env.API_URL}/calendarCallback`
)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'https://www.googleapis.com/auth/calendar')
url.searchParams.set('state', state)

return {
statusCode: 302,
headers: {
Location: url.toString(),
// The browser that started the flow is the only one that can finish
// it. SameSite=Lax is what lets the cookie ride along on the
// top-level redirect back from the provider.
'Set-Cookie':
`calendar_oauth_nonce=${nonce}; Path=/; HttpOnly; Secure; ` +
'SameSite=Lax; Max-Age=600',
},
}
}

export const handler = useRequireAuth({
handlerFn: calendarConnect,
getCurrentUser,
authDecoder,
})
api/src/functions/calendarCallback.ts
import type { APIGatewayProxyEvent } from 'aws-lambda'
import * as cookie from 'cookie'

import { SignedTokenError, verifySignedToken } from '@cedarjs/api'

import { db, Prisma } from 'src/lib/db'

interface CalendarOAuthState {
nonce: string
organizationId: string
userId: string
}

const rejected = { statusCode: 400, body: 'Invalid or expired OAuth state.' }

export const handler = async (event: APIGatewayProxyEvent) => {
let state: CalendarOAuthState

try {
state = verifySignedToken<CalendarOAuthState>(
event.queryStringParameters?.state,
{ purpose: 'calendar-oauth-state' }
)
} catch (e) {
if (e instanceof SignedTokenError) {
return rejected
}

throw e
}

// Bind the callback to the browser that started the flow
const cookies = cookie.parse(event.headers.cookie ?? '')

if (cookies.calendar_oauth_nonce !== state.nonce) {
return rejected
}

// Consume the nonce atomically. The primary key makes the insert fail for
// a replayed or concurrent callback, which is refused before the code is
// exchanged and before anything else is written.
try {
await db.oAuthNonce.create({ data: { nonce: state.nonce } })
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
return rejected
}

throw e
}

const tokens = await exchangeCodeForTokens(event.queryStringParameters?.code)

await db.integration.upsert({
where: { organizationId: state.organizationId },
create: { organizationId: state.organizationId, ...tokens },
update: tokens,
})

return {
statusCode: 302,
headers: {
Location: '/settings/integrations',
'Set-Cookie': 'calendar_oauth_nonce=; Path=/; Max-Age=0',
},
}
}

Because the organization comes from the verified token and nowhere else, the callback works the same whether or not any user session is present. A forged or expired state is rejected by verification, a state presented from a different browser is rejected by the cookie check, and a replayed state is rejected by the nonce insert, all before the code is exchanged or the integration is written.

Verification itself is stateless: a valid token is accepted every time it is presented until it expires. The two additions are independent and answer different questions:

  • The cookie binds the flow to a browser. Needed when the callback's effect depends on who is sitting at the browser, as with OAuth login and account linking. A password-set or email-confirmation link is meant to work from any browser, so it skips the cookie.
  • The recorded nonce makes the token single-use. Needed whenever acting on the same token twice would be a problem, which includes those password-set and confirmation links. An unsubscribe link, where a repeat is harmless, can skip it.

Design notes

These are the properties the helper guarantees so that you don't have to think about them at each call site:

  • Purpose binding is mandatory. There is no way to mint a token without a purpose, and no way to verify one without naming the purpose you expect. One secret can safely serve every flow in the app.
  • Expiry is mandatory and always enforced. A token with no exp claim is rejected even when its signature is valid.
  • Fails closed. A missing secret, a missing token, or a missing purpose all throw. Nothing is skipped because a value happened to be absent.
  • Only HS256 is accepted on verification. A token whose header names another algorithm, including none, is rejected before its signature is compared, which closes the algorithm-confusion class of JWT bugs.
  • Signatures are compared in constant time.

Testing

In tests, set SIGNED_TOKEN_SECRET like any other environment variable, and use fake timers to exercise expiry:

import { createSignedToken, verifySignedToken } from '@cedarjs/api'

test('rejects an expired confirmation link', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))

const token = createSignedToken({
payload: { userId: '1' },
purpose: 'email-confirmation',
expiresIn: '1h',
})

vi.setSystemTime(new Date('2026-01-01T02:00:00Z'))

expect(() =>
verifySignedToken(token, { purpose: 'email-confirmation' })
).toThrow('expired')

vi.useRealTimers()
})