Token Validation
Verify returned JWTs against UNQVerify JWKS endpoints and store them with the same semantics used by the SDK.
Token Validation
The SDK does not blindly trust the jwt query parameter.
It decodes the token to find the issuer, fetches the correct JWKS endpoint, verifies the RS256 signature, checks the expiry, and only then stores the token.
This page shows how to implement the same behavior yourself.
Install a JWT verification library
pnpm add joseHelper module
import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'
const VERIFICATION_COOKIE_KEY = 'unqverify_token'
const MIN_COOKIE_LIFETIME_SECONDS = 3600
type VerificationPayload = {
iss: string
exp: number
aldersverificeringdk_verification_result: boolean
aldersverificeringdk_verification_age: number
[key: string]: unknown
}
type ValidationResult =
| { ok: true; payload: VerificationPayload }
| {
ok: false
code: 'UNDER_AGE' | 'TOKEN_INVALID' | 'UNKNOWN_ERROR'
message: string
}
function getJwksUrlFromIssuer(issuer: string): string | null {
if (issuer === 'https://test.aldersverificering.dk') {
return 'https://test.api.aldersverificering.dk/well-known/openid-configuration/jwks'
}
if (issuer === 'https://aldersverificering.dk') {
return 'https://api.aldersverificering.dk/well-known/openid-configuration/jwks'
}
return null
}
export async function validateVerificationToken(token: string): Promise<ValidationResult> {
try {
const decoded = decodeJwt(token) as Partial<VerificationPayload>
const jwksUrl = getJwksUrlFromIssuer(decoded.iss ?? '')
if (!jwksUrl) {
return {
ok: false,
code: 'TOKEN_INVALID',
message: `Unknown issuer: ${decoded.iss ?? 'missing issuer'}`,
}
}
const JWKS = createRemoteJWKSet(new URL(jwksUrl))
const { payload } = await jwtVerify(token, JWKS, { algorithms: ['RS256'] })
const verifiedPayload = payload as VerificationPayload
if (verifiedPayload.iss !== decoded.iss) {
return {
ok: false,
code: 'TOKEN_INVALID',
message: 'Issuer mismatch after verification',
}
}
if (typeof verifiedPayload.exp !== 'number') {
return {
ok: false,
code: 'TOKEN_INVALID',
message: 'Missing or invalid exp claim',
}
}
if (!verifiedPayload.aldersverificeringdk_verification_result) {
return {
ok: false,
code: 'UNDER_AGE',
message: 'User does not meet the age requirement',
}
}
const secondsToExpiry = verifiedPayload.exp - Math.floor(Date.now() / 1000)
if (secondsToExpiry <= 0) {
return {
ok: false,
code: 'TOKEN_INVALID',
message: 'Token has expired',
}
}
return { ok: true, payload: verifiedPayload }
} catch {
return {
ok: false,
code: 'UNKNOWN_ERROR',
message: 'Unexpected error during token verification',
}
}
}
export function storeVerificationToken(token: string, exp: number): void {
const secondsToExpiry = exp - Math.floor(Date.now() / 1000)
const cookieLifetimeSeconds = Math.max(secondsToExpiry, MIN_COOKIE_LIFETIME_SECONDS)
const expiryDate = new Date(Date.now() + cookieLifetimeSeconds * 1000)
document.cookie =
`${VERIFICATION_COOKIE_KEY}=${token}; path=/; SameSite=Lax; Secure; expires=${expiryDate.toUTCString()}`
}
export function clearVerificationToken(): void {
document.cookie =
`${VERIFICATION_COOKIE_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax; Secure`
}
export function getVerificationToken(): string | null {
const value = `; ${document.cookie}`
const parts = value.split(`; ${VERIFICATION_COOKIE_KEY}=`)
if (parts.length === 2) {
return parts.pop()?.split(';').shift() ?? null
}
return null
}
export function getVerifiedAge(): number | null {
const token = getVerificationToken()
if (!token) {
return null
}
try {
const payload = decodeJwt(token) as Partial<VerificationPayload>
const now = Math.floor(Date.now() / 1000)
if (
payload.exp &&
payload.exp > now &&
payload.aldersverificeringdk_verification_result &&
typeof payload.aldersverificeringdk_verification_age === 'number'
) {
return payload.aldersverificeringdk_verification_age
}
return null
} catch {
return null
}
}
export function isVerified(): boolean {
return getVerifiedAge() !== null
}Issuer and JWKS mapping
Use the iss claim to choose the correct JWKS endpoint.
| Token issuer | JWKS endpoint |
|---|---|
https://test.aldersverificering.dk | https://test.api.aldersverificering.dk/well-known/openid-configuration/jwks |
https://aldersverificering.dk | https://api.aldersverificering.dk/well-known/openid-configuration/jwks |
Storage behavior that matches the SDK
If you want parity with the SDK and our storefront integrations:
- Store the token under the cookie name
unqverify_token - Set
SameSite=Lax; Secure; path=/ - Keep the cookie for at least 1 hour, even if the JWT expires sooner
- Clear it on logout or when you need to force a fresh verification
The example storage helper uses document.cookie, which means the token remains readable by JavaScript on the page. That is intentionally aligned with the current SDK behavior, but it also means XSS can expose the token. If you need a stronger boundary, exchange the verified JWT for a server-issued session and keep authorization checks on the backend.
UI helpers vs authorization
The getVerifiedAge() and isVerified() helpers are useful for client-side UI decisions such as whether to show a gate again.
They are not a substitute for server-side authorization. They read already-stored browser state and do not create a new security guarantee on their own.
Recommended server-side follow-up
Browser-side verification is enough to drive UI, but restricted business actions such as checkout acceptance should still validate the token on the server before you trust it.
That is how our WooCommerce integration works: the browser stores the JWT, and the backend validates it again before allowing the order to complete.
Common mistakes
- Using the wrong JWKS endpoint for the token issuer
- Accepting a JWT just because it decodes cleanly
- Forgetting to check
exp - Treating
UNDER_AGEas a token error instead of a valid business outcome