Gå til hovedindhold
Custom Integrations

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 jose

Helper 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 issuerJWKS endpoint
https://test.aldersverificering.dkhttps://test.api.aldersverificering.dk/well-known/openid-configuration/jwks
https://aldersverificering.dkhttps://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.

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_AGE as a token error instead of a valid business outcome

Next step

Apply these helpers in a popup flow

On this page