// File: api/endpoints/create-agekey-auth
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
The Create AgeKey authorization endpoint initiates the AgeKey creation flow for users. Your application should redirect the user's browser to this endpoint with the pre-generated PAR `request_uri` to start the AgeKey creation process.
:::info OAuth 2.0 Specification
This endpoint implements the [OAuth 2.0 Authorization Endpoint](https://datatracker.ietf.org/doc/html/rfc6749#section-3.1) with [Pushed Authorization Request (PAR)](https://datatracker.ietf.org/doc/html/rfc9126) support by using the returned `request_uri` parameter.
:::
## Authorization endpoint
```bash
curl -X GET "https://api.agekey.org/v1/oidc/create" \
-G \
-d "scope=openid" \
-d "response_type=none" \
-d "client_id=your-client-id" \
-d "redirect_uri=https://yourapp.com/agekey/create-callback" \
-d "request_uri=urn:agekey:request:AjcP1Yt7Np0"
```
```javascript
const params = new URLSearchParams({
scope: 'openid',
response_type: 'none',
client_id: 'your-client-id',
redirect_uri: 'https://yourapp.com/agekey/create-callback',
request_uri: 'urn:agekey:request:AjcP1Yt7Np0'
});
const authUrl = `https://api.agekey.org/v1/oidc/create?${params.toString()}`;
window.location.href = authUrl;
```
```python
from urllib.parse import urlencode
params = {
'scope': 'openid',
'response_type': 'none',
'client_id': 'your-client-id',
'redirect_uri': 'https://yourapp.com/agekey/create-callback',
'request_uri': 'urn:agekey:request:AjcP1Yt7Np0'
}
auth_url = f"https://api.agekey.org/v1/oidc/create?{urlencode(params)}"
```
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `scope` | string | Yes | Always set to `openid` |
| `response_type` | string | Yes | Always set to `none` |
| `client_id` | string | Yes | Your AgeKey client ID |
| `redirect_uri` | string | Yes | Same URI used in PAR request |
| `request_uri` | string | Yes | The Request URI from PAR response |
| `language` | string | No | Optional IETF BCP 47 language tag (for example `en-US`, `pt-BR`) to set the AgeKey UI language for this redirect. When omitted, the UI follows the visitor's browser language preferences. |
| `theme` | string | No | Sets the visual theme of the AgeKey UI for this redirect. Accepts `light`, `dark`, or `system`. When omitted, the UI defaults to `light`. |
### Response
On success, users are redirected to your `redirect_uri`:
```
https://yourapp.com/agekey/create-callback?state=abc123xyz789
```
:::note
Unlike the Use AgeKey flow, no `id_token` is returned. Success is indicated by the absence of an `error` parameter.
:::
---
// File: api/endpoints/create-agekey-par
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
The Create AgeKey PAR (Pushed Authorization Request) endpoint allows you to securely submit age verification data ahead of navigating the user to the AgeKey creation flow. This server-to-server endpoint stores the verification result and returns a short-lived `request_uri` that's used in the subsequent authorization flow.
:::info OAuth 2.0 Specification
This endpoint implements the [OAuth 2.0 Pushed Authorization Requests (PAR)](https://datatracker.ietf.org/doc/html/rfc9126) specification for secure parameter transmission.
:::
## PAR endpoint
```bash
curl -X POST "https://api.agekey.org/v1/oidc/create/par" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=your-client-id" \
-d "client_secret=your-client-secret" \
-d "scope=openid" \
-d "response_type=none" \
-d "type=age_verification" \
-d "redirect_uri=https://yourapp.com/agekey/create-callback" \
-d "state=abc123xyz789" \
-d "authorization_details=%5B%7B%22type%22%3A%22age_verification%22%2C%22age%22%3A%7B%22date_of_birth%22%3A%222000-01-02%22%7D%2C%22method%22%3A%22id_doc_scan%22%2C%22verification_id%22%3A%22b861f598-f58a-49e9-b98a-a2ee5bdfb4bb%22%2C%22verified_at%22%3A%222025-10-07T12%3A34%3A56Z%22%2C%22attributes%22%3A%7B%22face_match_performed%22%3Atrue%2C%22issuing_country%22%3A%22US%22%7D%2C%22provenance%22%3A%22%2Fveratad%2Froc%22%7D%5D"
```
```javascript
const axios = require('axios');
const qs = require('querystring');
const formData = {
client_id: 'your-client-id',
client_secret: 'your-client-secret',
scope: 'openid',
response_type: 'none',
type: 'age_verification',
redirect_uri: 'https://yourapp.com/agekey/create-callback',
state: 'abc123xyz789',
authorization_details: JSON.stringify([{
type: 'age_verification',
age: { date_of_birth: '2000-01-02' },
method: 'id_doc_scan',
verification_id: 'b861f598-f58a-49e9-b98a-a2ee5bdfb4bb',
verified_at: '2025-10-07T12:34:56Z',
attributes: {
face_match_performed: true,
issuing_country: 'US'
},
provenance: '/veratad/roc'
}])
};
const response = await axios.post(
'https://api.agekey.org/v1/oidc/create/par',
qs.stringify(formData),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
```
```python
import requests
import json
form_data = {
'client_id': 'your-client-id',
'client_secret': 'your-client-secret',
'scope': 'openid',
'response_type': 'none',
'type': 'age_verification',
'redirect_uri': 'https://yourapp.com/agekey/create-callback',
'state': 'abc123xyz789',
'authorization_details': json.dumps([{
'type': 'age_verification',
'age': {'date_of_birth': '2000-01-02'},
'method': 'id_doc_scan',
'verification_id': 'b861f598-f58a-49e9-b98a-a2ee5bdfb4bb',
'verified_at': '2025-10-07T12:34:56Z',
'attributes': {
'face_match_performed': True,
'issuing_country': 'US'
},
'provenance': '/veratad/roc'
}])
}
response = requests.post(
'https://api.agekey.org/v1/oidc/create/par',
data=form_data,
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
```
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | string | Yes | Your AgeKey client ID |
| `client_secret` | string | Yes | Your AgeKey client secret |
| `scope` | string | Yes | Always set to `openid` |
| `response_type` | string | Yes | Always set to `none` |
| `type` | string | Yes | Always set to `age_verification` |
| `redirect_uri` | string | Yes | Where users return after AgeKey creation |
| `state` | string | Yes | Client-generated value for CSRF protection and maintaining application state |
| `authorization_details` | string | Yes | URL-encoded JSON array of verification data |
### Authorization details format
The `authorization_details` parameter must be a URL-encoded JSON array containing age signal information:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | Yes | Always `age_verification` |
| `age` | object | Yes | Age information object |
| `age.date_of_birth` | string | No* | Date of birth in ISO 8601 format (`YYYY-MM-DD`) |
| `age.at_least_years` | number | No* | Minimum age threshold verified |
| `age.years` | number | No* | Exact age in years |
| `method` | string | Yes | Verification method used (see supported methods below) |
| `verification_id` | string | Yes | A unique ID for revoking or auditing the verification. The ID is chosen by you, the contributor, not by the AgeKey service. Up to 100 characters. Alphanumeric plus _+/=.-. UUID v7 format is recommended. |
| `verified_at` | string | Yes | Verification completion time (date or date-time in ISO 8601) |
| `attributes` | object | No | Method-specific verification attributes |
| `provenance` | string | Yes | Verification source path identifying where the age signal was obtained (for example `/veratad/roc`). Must be a value from the allowlist. |
*Either `date_of_birth`, `at_least_years`, or `years` is required depending on the verification method.
:::note Valid provenances
Provenance values are validated against an allowlist. You must pre-register the provenance values you use with your AgeKey representative. Contact AgeKey for the current list of valid provenances or to request adding a new provenance for your verification source.
:::
**Example:**
```json
[
{
"type": "age_verification",
"age": {
"date_of_birth": "2000-01-02"
},
"method": "id_doc_scan",
"verification_id": "b861f598-f58a-49e9-b98a-a2ee5bdfb4bb",
"verified_at": "2025-10-07T12:34:56Z",
"attributes": {
"face_match_performed": true,
"issuing_country": "US"
},
"provenance": "/veratad/roc"
}
]
```
### Supported verification methods
| Method | Age Format | Required Attributes* | Optional Attributes* |
|--------|------------|---------------------|---------------------|
| `email_age_estimation` | `at_least_years` | None | None |
| `facial_age_estimation` | `at_least_years` | None | `on_device` (boolean) |
| `national_id_number` | `date_of_birth` preferred | `issuing_country` (ISO 3166-1 alpha-2) | None |
| `digital_credential` | `date_of_birth` preferred | `platform` (`singpass`, `connect_id`, `privy`, `digilocker`, `korean_real_name`), `issuing_country` (ISO 3166-1 alpha-2) | None |
| `id_doc_scan` | `date_of_birth` preferred | None | `face_match_performed` (boolean), `issuing_country` (ISO 3166-1 alpha-2) |
| `payment_card_network` | `at_least_years: 18` | `card_type` (`credit`, `debit`, `unknown`) | None |
*Attributes parsing is strict, if the attributes given for a particular method aren't required or optional then the request fails.
### Response
On success, returns a `request_uri` that expires in 90 seconds:
```json
{
"request_uri": "urn:agekey:request:AjcP1Yt7Np0",
"expires_in": 90
}
```
:::warning
**Important:** The `request_uri` expires in 90 seconds. Users must complete AgeKey creation within this window.
:::
---
// File: api/endpoints/upgrade-agekey
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
The Upgrade AgeKey endpoint updates an existing AgeKey with a new verification result. The AgeKey to upgrade is identified by the access token obtained from exchanging the authorization code returned when using the `agekey.upgrade` scope.
:::note Browser step before this API call
The upgrade flow starts by redirecting the user to **`/v1/oidc/use`** with the `agekey.upgrade` scope. That redirect URL supports the same optional **`language`** query parameter as the standard Use flow; see [Use AgeKey Authorization](/api/endpoints/use-agekey#parameters).
:::
## Upgrade endpoint
```bash
curl -X POST "https://api.agekey.org/v1/agekey/upgrade" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer " \
-d '{"authorization_details":[{"type":"age_verification","age":{"date_of_birth":"2000-01-02"},"method":"id_doc_scan","verification_id":"b861f598-f58a-49e9-b98a-a2ee5bdfb4bb","verified_at":"2025-10-07T12:34:56Z","attributes":{"face_match_performed":true,"issuing_country":"US"},"provenance":"/veratad/roc"}]}'
```
```javascript
const axios = require('axios');
const accessToken = 'your-access-token';
const response = await axios.post(
'https://api.agekey.org/v1/agekey/upgrade',
{
authorization_details: [{
type: 'age_verification',
age: { date_of_birth: '2000-01-02' },
method: 'id_doc_scan',
verification_id: 'b861f598-f58a-49e9-b98a-a2ee5bdfb4bb',
verified_at: '2025-10-07T12:34:56Z',
attributes: {
face_match_performed: true,
issuing_country: 'US'
},
provenance: '/veratad/roc'
}]
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
}
}
);
```
```python
import requests
import base64
import json
access_token = 'your-access-token'
response = requests.post(
'https://api.agekey.org/v1/agekey/upgrade',
json={
'authorization_details': [{
'type': 'age_verification',
'age': {'date_of_birth': '2000-01-02'},
'method': 'id_doc_scan',
'verification_id': 'b861f598-f58a-49e9-b98a-a2ee5bdfb4bb',
'verified_at': '2025-10-07T12:34:56Z',
'attributes': {
'face_match_performed': True,
'issuing_country': 'US'
},
'provenance': '/veratad/roc'
}]
},
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}'
}
)
```
### Headers
| Header | Value | Required |
|--------|-------|----------|
| `Content-Type` | `application/json` | Yes |
| `Authorization` | `Bearer ` | Yes |
### Body
| Property | Type | Required | Description |
|-----------|------|----------|-------------|
| `authorization_details` | array | Yes | JSON array of verification data |
### Authorization details format
The `authorization_details` parameter must be a JSON array containing age verification information:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | Yes | Always `age_verification` |
| `age` | object | Yes | Age information object |
| `age.date_of_birth` | string | No* | Date of birth in ISO 8601 format (`YYYY-MM-DD`) |
| `age.at_least_years` | number | No* | Minimum age threshold verified |
| `age.years` | number | No* | Exact age in years |
| `method` | string | Yes | Verification method used (see supported methods below) |
| `verification_id` | string | Yes | A unique ID for revoking or auditing the verification. The ID is chosen by you, the contributor, not by the AgeKey service. Up to 100 characters. Alphanumeric plus _+/=.-. UUID v7 format is recommended. |
| `verified_at` | string | Yes | Verification completion time (date or date-time in ISO 8601) |
| `attributes` | object | No | Method-specific verification attributes |
| `provenance` | string | Yes | Verification source path identifying where the age signal was obtained (for example `/veratad/roc`). Must be a value from the allowlist. |
*Either `date_of_birth`, `at_least_years`, or `years` is required depending on the verification method.
:::note Valid provenances
Provenance values are validated against an allowlist. You must pre-register the provenance values you use with your AgeKey representative. Contact AgeKey for the current list of valid provenances or to request adding a new provenance for your verification source.
:::
**Example:**
```json
[
{
"type": "age_verification",
"age": {
"date_of_birth": "2000-01-02"
},
"method": "id_doc_scan",
"verification_id": "b861f598-f58a-49e9-b98a-a2ee5bdfb4bb",
"verified_at": "2025-10-07T12:34:56Z",
"attributes": {
"face_match_performed": true,
"issuing_country": "US"
},
"provenance": "/veratad/roc"
}
]
```
### Supported verification methods
| Method | Age Format | Required Attributes* | Optional Attributes* |
|--------|------------|---------------------|---------------------|
| `email_age_estimation` | `at_least_years` | None | None |
| `facial_age_estimation` | `at_least_years` | None | `on_device` (boolean) |
| `national_id_number` | `date_of_birth` preferred | `issuing_country` (ISO 3166-1 alpha-2) | None |
| `digital_credential` | `date_of_birth` preferred | `platform` (`singpass`, `connect_id`, `privy`, `digilocker`, `korean_real_name`), `issuing_country` (ISO 3166-1 alpha-2) | None |
| `id_doc_scan` | `date_of_birth` preferred | None | `face_match_performed` (boolean), `issuing_country` (ISO 3166-1 alpha-2) |
| `payment_card_network` | `at_least_years: 18` | `card_type` (`credit`, `debit`, `unknown`) | None |
*Attributes parsing is strict, if the attributes given for a particular method aren't required or optional then the request fails.
### Response
On success, returns a status code of 200:
```json
"OK"
```
:::note
Success is indicated by the 200 status code.
:::
---
// File: api/endpoints/use-agekey
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
The Use AgeKey authorization endpoint initiates the age verification flow for users who already have an AgeKey. Your application should redirect the user's browser to this endpoint with the appropriate parameters to start the verification process.
:::info OIDC Specification
This endpoint implements the [OpenID Connect Implicit Flow](https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth) with `id_token` response type.
:::
## Authorization endpoint
```bash
curl -X GET "https://api.agekey.org/v1/oidc/use" \
-G \
-d "scope=openid" \
-d "response_type=id_token" \
-d "client_id=your-client-id" \
-d "redirect_uri=https://yourapp.com/agekey/callback" \
-d "state=abc123xyz789" \
-d "nonce=nonce456def" \
-d "claims=%7B%22age_thresholds%22%3A%5B13%2C18%5D%7D"
```
```javascript
const params = new URLSearchParams({
scope: 'openid',
response_type: 'id_token',
client_id: 'your-client-id',
redirect_uri: 'https://yourapp.com/agekey/callback',
state: 'abc123xyz789',
nonce: 'nonce456def',
claims: JSON.stringify({ age_thresholds: [13, 18] })
});
const authUrl = `https://api.agekey.org/v1/oidc/use?${params.toString()}`;
window.location.href = authUrl;
```
```python
from urllib.parse import urlencode
import json
params = {
'scope': 'openid',
'response_type': 'id_token',
'client_id': 'your-client-id',
'redirect_uri': 'https://yourapp.com/agekey/callback',
'state': 'abc123xyz789',
'nonce': 'nonce456def',
'claims': json.dumps({'age_thresholds': [13, 18]})
}
auth_url = f"https://api.agekey.org/v1/oidc/use?{urlencode(params)}"
```
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `scope` | string | Yes | Always set to `openid`, optionally can also set `agekey.upgrade` if upgrades are allowed |
| `response_type` | string | Yes | Always set to `id_token` |
| `response_mode` | string | No | How the authorization response is returned to your `redirect_uri`: `fragment` (default), `query`, or `form_post`. |
| `client_id` | string | Yes | Your AgeKey client ID |
| `redirect_uri` | string | Yes | Where users return after verification |
| `state` | string | Yes | Client-generated value for CSRF protection and maintaining application state |
| `nonce` | string | Yes | Random value for replay protection |
| `claims` | string | Yes | URL-encoded JSON specifying age thresholds and other constraints |
| `can_create` | string | No | When present and `true`, users see both "Use AgeKey" and "Create AgeKey". When omitted or not `true`, only "Use AgeKey" is shown. |
| `language` | string | No | Optional IETF BCP 47 language tag (for example `en-US`, `pt-BR`) to set the AgeKey UI language for this redirect. When omitted, the UI follows the visitor's browser language preferences. Applies to use and upgrade flows, which both use this authorization URL. |
| `theme` | string | No | Sets the visual theme of the AgeKey UI for this redirect. Accepts `light`, `dark`, or `system`. When omitted, the UI defaults to `light`. Applies to use and upgrade flows, which both use this authorization URL. |
### Claims parameter
The `claims` parameter must be a URL-encoded JSON object specifying which age thresholds to verify and filtering criteria:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `age_thresholds` | array | Yes | Array of age thresholds to verify (for example `[13, 18, 21]`) |
| `allowed_methods` | array | No | Array of verification methods to accept. (for example `["id_doc_scan", "payment_card_network"]`) All verification methods are considered when not provided. |
| `verified_after` | string | No | ISO 8601 date/datetime - only accept verifications after this date |
| `provenance` | object | No | Filter by verification provider origin (see provenance structure below) |
| `overrides` | object | No | Method-specific filtering rules (see overrides structure below) |
**Provenance structure:**
Provenance filtering allows you to control which verification providers are accepted based on their origin path. Each provider has a unique provenance path that identifies the verification technology used.
| Field | Type | Description |
|-------|------|-------------|
| `allowed` | array | Allowed provenance patterns. Use `*` suffix for prefix matching (for example `/veratad/*` matches all Veratad providers) |
| `denied` | array | Denied provenance patterns. Takes precedence over allowed. Use to exclude specific providers |
**Pattern matching:**
- Patterns must start with `/` and use lowercase letters, numbers, and underscores
- Use `/*` suffix for prefix matching (for example `/veratad/*` matches `/veratad/internal`, `/veratad/trinsic`, and other paths under that prefix)
- Exact paths match only that specific provider (for example `/stripe` only matches Stripe)
- Maximum 10 patterns per array, each up to 100 characters
**Example with provenance filtering:**
```json
{
"age_thresholds": [18],
"provenance": {
"allowed": ["/veratad/*", "/stripe", "/singpass"],
"denied": ["/veratad/internal"]
}
}
```
In this example:
- All Veratad providers are allowed via the `/veratad/*` wildcard
- Stripe and Singpass verifications are explicitly allowed
- Veratad internal verifications are excluded (denied takes precedence over allowed)
**Available provenances:**
| Provenance | Description |
|------------|-------------|
| `/connect_id` | Connect ID verification |
| `/stripe` | Stripe identity verification |
| `/inicis` | Inicis verification (Korea) |
| `/singpass` | Singpass verification (Singapore) |
| `/privy` | Privy verification |
| `/spruce_id` | Spruce ID verification |
| `/verify_my` | VerifyMy verification |
| `/privately` | Privately verification |
| `/veratad/internal` | Veratad internal verification |
| `/veratad/trinsic` | Veratad via Trinsic |
| `/veratad/cra` | Veratad via CRA verification |
| `/veratad/roc` | Veratad via ROC verification |
| `/yoti` | Yoti verification |
| `/meta` | Meta verification |
| `/friendly_id/amazon` | Friendly ID via Amazon |
| `/paymentico` | Paymentico verification |
**Overrides structure:**
Each method key inside `overrides` accepts the following fields:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `min_age` | integer | No* | A single minimum age threshold for this method (0–150). For `facial_age_estimation`, exactly one of `min_age` or `age_thresholds` is required. |
| `age_thresholds` | array | No* | Per-threshold overrides that map 1:1 by index to the root `age_thresholds`. Each entry is the minimum age (for this method) required to satisfy the corresponding root threshold. Length must equal root `age_thresholds` length. |
| `verified_after` | string | No | Override `verified_after` for this specific method |
| `attributes` | object | No | Method-specific attribute requirements |
*For `facial_age_estimation`, one of `min_age` or `age_thresholds` must be provided.
**Example with method and attribute filtering:**
```json
{
"age_thresholds": [13, 18],
"allowed_methods": ["id_doc_scan", "payment_card_network"],
"verified_after": "2024-01-01",
"overrides": {
"id_doc_scan": {
"attributes": {
"issuing_country": ["US", "GB"],
"face_match_performed": true
}
},
"payment_card_network": {
"attributes": {
"card_type": ["credit"]
}
}
}
}
```
**Example with `age_thresholds` override for facial age estimation:**
```json
{
"age_thresholds": [13, 18],
"overrides": {
"facial_age_estimation": {
"age_thresholds": [16, 21]
}
}
}
```
In this example, root threshold `13` requires the facial age estimation to report at least `16`, and root threshold `18` requires at least `21`.
**Example with `min_age` override for facial age estimation:**
```json
{
"age_thresholds": [18],
"overrides": {
"facial_age_estimation": {
"min_age": 21
}
}
}
```
### Response
On success, users are redirected to your `redirect_uri` with an `id_token`, `state`, and potentially a `code` in the URL fragment:
```
https://yourapp.com/agekey/callback#
id_token=eyJhbGc...long-jwt-string...&
state=abc123xyz789
```
:::note User chose to create an AgeKey
When you sent `can_create=true` and the user was shown the use-or-create dialog and chose to create an AgeKey, they're redirected with `create_requested=true` and no `id_token`. Your app should send them to complete verification with another method (such as ID document scan). Once that verification succeeds, direct them to the [Create AgeKey](/guides/create-agekey) flow.
```
https://yourapp.com/agekey/callback#
state=abc123xyz789&
create_requested=true
```
:::
| Parameter | When present | Description |
|-------------------|-------------------|-------------|
| `id_token` | Success | Signed JWT with age threshold results. Must be validated on your server before trusting the results. |
| `state` | Always | The `state` value you sent; verify it for CSRF protection. |
| `code` | With `agekey_upgrade` scope | Returned when using the `agekey_upgrade` scope; can be exchanged to upgrade an AgeKey with a new verification. |
| `create_requested`| When `can_create=true` was sent | Present and `true` when the user chose "Create AgeKey" in the use-or-create dialog. Send the user to complete verification with another method; after success, direct them to the [Create AgeKey](/guides/create-agekey) flow. No `id_token` is returned. Only returned if the request included `can_create=true`. |
| `error` | On error | Error code (for example, `access_denied` when the user clicks back or abandons the flow); handle appropriately. |
:::note
The `id_token` contains age threshold results and must be validated on your server before trusting the results.
The `code` is returned when using the `agekey.upgrade` scope and can be used to upgrade an AgeKey with a new verification.
:::
### ID token claims
The decoded `id_token` JWT payload contains the following claims:
| Claim | Type | Description |
|-------|------|-------------|
| `sub` | string | **Session ID**. A unique identifier for this verification session. You should log this value for invalidation auditing (see note below). |
| `iss` | string | Token issuer. Always `https://api.agekey.org/v1/oidc/use`. |
| `aud` | array | Audience. Contains your `client_id`. |
| `iat` | number | Issued-at timestamp (UNIX epoch seconds). |
| `exp` | number | Expiration timestamp (UNIX epoch seconds). Token is valid for 10 minutes. |
| `nonce` | string | The `nonce` value from your authorization request. Must match to prevent replay attacks. |
| `age_thresholds` | object | Map of requested threshold to boolean result. For example `{ "13": true, "18": false }`. |
| `req_claims_hash` | string | Base64url-encoded SHA-256 hash of the `claims` JSON you sent, for request integrity verification. |
| `c_hash` | string | *(Only with `agekey.upgrade` scope)* Code hash per [OIDC spec §3.3.2.11](https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.3.2.11). |
:::warning Log the session ID
The `sub` claim is the **session ID** for this verification. Persist this value in your system alongside the verification outcome. If an age signal is later revoked or found to be fraudulent, this session ID is used for **invalidation auditing** to identify which verifications were affected. Without it, impacted sessions can't be traced back to your users.
---
// File: api/endpoints/use-token
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
The Use Token endpoint exchanges the authorization code you received from a Use AgeKey request (when using the `agekey.upgrade` scope and all age thresholds are false) for an access token. This access token is used to authorize the upgrade request.
:::info OAuth 2.0 Specification
This endpoint implements the [OAuth 2.0 Token Endpoint](https://datatracker.ietf.org/doc/html/rfc6749#section-3.2) for authorization code exchange.
:::
## Token endpoint
```bash
curl -X POST "https://api.agekey.org/v1/oidc/use/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Authorization: Basic " \
-d "grant_type=authorization_code" \
-d "code=your-authorization-code"
```
```javascript
const axios = require('axios');
const qs = require('querystring');
const clientId = 'your-client-id';
const clientSecret = 'your-client-secret';
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const formData = {
grant_type: 'authorization_code',
code: 'your-authorization-code'
};
const response = await axios.post(
'https://api.agekey.org/v1/oidc/use/token',
qs.stringify(formData),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${credentials}`
}
}
);
```
```python
import requests
import base64
client_id = 'your-client-id'
client_secret = 'your-client-secret'
credentials = base64.b64encode(
f'{client_id}:{client_secret}'.encode()
).decode()
form_data = {
'grant_type': 'authorization_code',
'code': 'your-authorization-code'
}
response = requests.post(
'https://api.agekey.org/v1/oidc/use/token',
data=form_data,
headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': f'Basic {credentials}'
}
)
```
### Headers
| Header | Value | Required |
|--------|-------|----------|
| `Content-Type` | `application/x-www-form-urlencoded` | Yes |
| `Authorization` | `Basic ` | Yes |
### Body
| Property | Type | Required | Description |
|-----------|------|----------|-------------|
| `grant_type` | string | Yes | Always set to `authorization_code` |
| `code` | string | Yes | The authorization code returned from the Use AgeKey request when using `agekey.upgrade` scope and all thresholds are false |
### Response
On success, returns an object with the `access_token` property. This access token can be used to authorize the upgrade request to the `/v1/agekey/upgrade` endpoint.
```json
{
"access_token": "eyJhbGc...",
"token_type": "Bearer",
"expires_in": 10800
}
```
:::warning
**Important:** The `access_token` expires in 10800 seconds (3 hours). You should use it promptly for the upgrade request.
:::
---
// File: api/introduction
## Welcome
AgeKey provides a comprehensive API for privacy-preserving age verification. The API is built on OpenID Connect (OIDC) standards, making it familiar to developers who have worked with OAuth 2.0 or social login integrations.
## Authentication
All API endpoints require proper client credentials and use standard OIDC authentication flows.
### Client credentials
You'll need to obtain these from AgeKey:
- `client_id`: Your app identifier
- `client_secret`: Your app secret (keep this secure!)
### Security best practices
:::warning Important
Never expose your `client_secret` in front-end code. Always keep it secure on your server.
:::
- Store credentials securely
- Implement proper state and nonce validation
- Validate all JWT tokens on your server
## Base URLs
**Use AgeKey Service:**
```
https://api.agekey.org/v1/oidc/use
```
**Create AgeKey Service:**
```
https://api.agekey.org/v1/oidc/create
```
### Optional language preference
On browser redirects to **`/v1/oidc/use`** (including when you include the `agekey.upgrade` scope for upgrades) and **`/v1/oidc/create`**, you can add a **`language`** query string parameter with an IETF BCP 47 tag (for example `en-US`, `pt-BR`) to set the AgeKey UI language for that request. When you omit it, the UI follows the visitor's browser language preferences.
**Verification Keys:**
```
https://api.agekey.org/.well-known/jwks.json
```
## Integration flows
AgeKey supports two main integration patterns:
## OpenID Connect discovery
AgeKey supports OIDC discovery for easy integration with certified libraries.
**Use AgeKey Discovery:**
```
https://api.agekey.org/v1/oidc/use/.well-known/openid-configuration
```
**Create AgeKey Discovery:**
```
https://api.agekey.org/v1/oidc/create/.well-known/openid-configuration
```
## Response formats
### Age threshold results
When using the Use AgeKey flow, you'll receive age threshold results in this format:
```json
{
"age_thresholds": {
"13": true,
"18": false,
"21": false
}
}
```
### Error responses
Standard OIDC error responses and error redirect parameters are used.
## Support
### Get help
Contact the support team for integration assistance: [support@AgeKey.org](mailto:support@agekey.org)
---
// File: guides/create-agekey
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
## Overview
The Create AgeKey flow allows users to save their age verification results as an AgeKey for future use. This guide walks through implementing this flow step-by-step.
::::note Already familiar with OAuth/OIDC?
This guide uses OAuth 2.0 Pushed Authorization Request (PAR) plus an OpenID Connect authorization request with `response_type=none` (no ID token is returned). If you already know these flows, you mainly need the AgeKey-specific piece: the `authorization_details` payload format (see the [Create AgeKey PAR API Reference](/api/endpoints/create-agekey-par#authorization-details-format)). The rest follows the standard pattern.
::::
**Time to implement:** ~3 hours
---
## High-level flow overview
Before diving into code, it's important to understand what happens during the Create AgeKey flow from start to finish.
### The user's journey
**1. User completes age verification**
A user has just completed age verification by using your existing verification method - maybe they scanned their driver's license, verified their credit card, or completed a facial age estimation. Your system now has proof of their age.
**2. Offer to save as AgeKey**
Instead of making them repeat this verification process every time they need to prove their age, you offer them the option: "Save as AgeKey for quick verification next time?" This is completely optional - the user can decline and continue normally.
**3. Your server prepares the data**
If the user wants to create an AgeKey, your server securely sends the verification details to AgeKey's service through a server-to-server API call. This includes:
- What type of verification was performed (ID scan, credit card)
- The age information obtained (date of birth or age threshold)
- When the verification happened
- A unique ID for audit purposes
**4. AgeKey acknowledges receipt**
AgeKey receives your verification data and generates a special temporary token (called a `request_uri`). This token is similar to a claim ticket - it proves you've submitted valid verification data and is needed for the next step. It expires in 90 seconds.
**5. Redirect to AgeKey service**
Your app redirects the user's browser to AgeKey, passing along that temporary token. The user now sees AgeKey's interface for creating their passkey.
**6. User creates their passkey**
The AgeKey service prompts the user to create a passkey (using Touch ID, Face ID, Windows Hello, or similar). This passkey is stored on their device and associated with the age information you verified. The actual creation happens entirely on their device - no sensitive biometric data is uploaded.
**7. Return to Your app**
Once the passkey is created (or if the user decides not to create one), their browser is redirected back to your app. Unlike the Use AgeKey flow, no verification token comes back - the redirect simply confirms whether or not the AgeKey was created.
**8. Confirm success**
Your app checks whether the creation was successful and shows the user an appropriate message: "AgeKey created. You can now use it for quick age verification" or "You can create an AgeKey later from your account settings."
### Visual flow diagram
```mermaid
sequenceDiagram
actor User
participant App as "Your App (Frontend)"
participant Server as "Your App (Server)"
participant AgeKey as "AgeKey API (Server)"
participant AgeKey UI as "AgeKey Service (User Interface)"
User->>Server: Complete age verification (ID scan, credit card, etc.)
Server->>Server: Verification successful
Server->>App: Offer "Save as AgeKey?"
User->>App: Click "Yes, save as AgeKey"
App->>Server: Initiate Create AgeKey flow
Server->>Server: Generate state token, Build verification details
Server->>AgeKey: POST /par, Submit verification data
AgeKey->>Server: Return request_uri (expires in 90s)
Server->>App: Return authorization URL
App->>AgeKey UI: Redirect user with request_uri
Note over AgeKey UI: User creates passkey (Touch ID, Face ID, etc.)
AgeKey UI->>App: Redirect back to app
App->>Server: Confirm completion
alt AgeKey Created
Server->>App: Success!
App->>User: "AgeKey created successfully"
else User Declined
Server->>App: User declined
App->>User: "You can create later"
end
```
### Key technical concepts
**Pushed Authorization Request (PAR)**
This flow uses a special OAuth 2.0 extension called PAR. Instead of putting all the verification details in a URL (which could be visible in browser history), you send them securely from your server to AgeKey's server. You get back a short `request_uri` token to use instead.
:::note Why PAR?
- **Privacy**: Sensitive verification details never appear in URLs or browser history
- **Security**: Your `client_secret` stays on your server, never exposed to browsers
- **Size**: Verification data can be large; PAR avoids URL length limits
:::
**Server-to-server communication**
The most sensitive part of this flow (submitting verification data) happens entirely server-to-server. The user's browser is never involved in transmitting verification details.
**No ID token response**
Unlike the Use AgeKey flow, you don't receive an `id_token` back. The redirect simply confirms whether the user completed the flow. The AgeKey itself is stored on the user's device - you don't receive it or manage it.
**Time sensitivity**
The `request_uri` expires in 90 seconds. The user needs to create their passkey within this window, or you'll need to generate a new one. This prevents stale verification data from being reused.
### What you'll need to build
1. **Verification Data Formatting**: Logic to structure your verification results in the format AgeKey expects
2. **PAR Request Handler**: Server-side code to submit verification data to AgeKey's API
3. **Authorization URL Builder**: Code to construct the URL for redirecting users to AgeKey
4. **Callback Handler**: A page that receives users when they return from AgeKey
5. **User Experience Flow**: UI to offer AgeKey creation and show confirmation messages
### When to offer AgeKey creation
:::tip Best Practices
- ✅ **After successful verification**: Offer immediately when verification is fresh
- ✅ **Make it optional**: Never force users to create an AgeKey
- ✅ **Explain the benefit**: "Save time on future verifications"
- ✅ **Handle expiration gracefully**: If the 90-second window passes, offer to try again
- ❌ **Don't offer for failed verifications**: Only offer after successful age verification
:::
Now you can implement each piece step by step.
---
## Step 1: Complete age verification
Before creating an AgeKey, the user must complete an age verification by using your existing verification vendor (ID scan, credit card, facial estimation).
After successful verification, you should have:
- Verification method used (for example, `id_doc_scan`, `payment_card_network`)
- Age information (date of birth, age in years, or minimum age)
- Timestamp of verification
- Unique verification ID (you generate this)
- Provenance (verification source path from the allowlist; contact AgeKey for the current list)
---
## Step 2: Build authorization details
The authorization details is a JSON array containing your verification result. This is what you'll send to AgeKey. For complete details on the authorization details structure, see the [Create AgeKey PAR API Reference](/api/endpoints/create-agekey-par#authorization-details-format).
### Structure
Each item in the array must include a required `provenance` field that identifies where the age signal was obtained. Valid provenance values are allowlisted, and you must pre-register the provenance values you use with your AgeKey representative. See the [Create AgeKey PAR API Reference](/api/endpoints/create-agekey-par#authorization-details-format) for details and how to obtain the current list.
```json
[
{
"type": "age_verification",
"method": "",
"age": { },
"verified_at": "",
"verification_id": "",
"attributes": { },
"provenance": ""
}
]
```
### Age object formats
Choose **one** format based on what your verification provides:
Most common format when you have the user's exact date of birth.
```json
"age": {
"date_of_birth": "2000-01-02"
}
```
Use when you know the user's exact age in years.
```json
"age": {
"years": 24
}
```
Use when exact age is unknown but you can confirm minimum age.
```json
"age": {
"at_least_years": 18
}
```
### Supported verification methods
| Method | When to Use | Age Format | Required Attributes |
|--------|-------------|------------|---------------------|
| `id_doc_scan` | ID document verification | `date_of_birth` preferred | None |
| `payment_card_network` | Credit/debit card verification | `at_least_years` | `card_type` (required: `credit`, `debit`, or `unknown`) |
| `facial_age_estimation` | Face scan age estimation | `at_least_years` | None |
| `email_age_estimation` | email-based age inference | `at_least_years` | None |
| `national_id_number` | National ID number verification | `date_of_birth` preferred | `issuing_country` (ISO 3166-1 alpha-2) |
| `digital_credential` | Digital credential verification | `date_of_birth` preferred | `platform` (`singpass`, `connect_id`, `privy`, `digilocker`, `korean_real_name`), `issuing_country` (ISO 3166-1 alpha-2) |
### Complete examples
```json
[
{
"type": "age_verification",
"method": "id_doc_scan",
"age": {
"date_of_birth": "2000-01-02"
},
"verified_at": "2025-10-07T12:34:56Z",
"verification_id": "80760254-837a-4008-87f2-6071648a4893",
"attributes": {
"face_match_performed": true
},
"provenance": "/veratad/roc"
}
]
```
```json
[
{
"type": "age_verification",
"method": "payment_card_network",
"age": {
"at_least_years": 18
},
"verified_at": "2025-10-07T12:34:56Z",
"verification_id": "22df5238-1d04-4cd4-a2bb-30ab41b0d0da",
"attributes": {
"card_type": "debit"
},
"provenance": "/veratad/roc"
}
]
```
```json
[
{
"type": "age_verification",
"method": "facial_age_estimation",
"age": {
"at_least_years": 31
},
"verified_at": "2025-10-07T12:34:56Z",
"verification_id": "d558d350-fe7e-49f5-8ffe-0448fe8920a6",
"attributes": {
"on_device": false
},
"provenance": "/veratad/roc"
}
]
```
```json
[
{
"type": "age_verification",
"method": "email_age_estimation",
"age": { "at_least_years": 18 },
"verified_at": "2025-10-07T12:34:56Z",
"verification_id": "e6a2a6f0-1d1b-4a7a-8b3c-1234567890ab",
"provenance": "/veratad/roc"
}
]
```
```json
[
{
"type": "age_verification",
"method": "national_id_number",
"age": { "date_of_birth": "2000-01-02" },
"verified_at": "2025-10-07T12:34:56Z",
"verification_id": "9b9b0a23-7a2b-4d0f-a1d1-abcdefabcdef",
"attributes": {
"issuing_country": "US"
},
"provenance": "/veratad/roc"
}
]
```
```json
[
{
"type": "age_verification",
"method": "digital_credential",
"age": { "date_of_birth": "2000-01-02" },
"verified_at": "2025-10-07T12:34:56Z",
"verification_id": "a3c7e912-5b8d-4f2a-9c1e-0123456789ab",
"attributes": {
"platform": "singpass",
"issuing_country": "SG"
},
"provenance": "/singpass"
}
]
```
---
## Step 3: Generate security token
Generate a random `state` value for CSRF protection. Store it in your session.
**Code Example (JavaScript):**
```javascript
function generateState() {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
const state = generateState();
// Store in session for later verification
sessionStorage.setItem('agekey_create_state', state);
```
**Code Example (Python):**
```python
import secrets
state = secrets.token_urlsafe(16)
# Store in session
session['agekey_create_state'] = state
```
---
## Step 4: Send pushed authorization request (PAR)
Now make a server-to-server POST request to submit your verification data.
### Endpoint
```
POST https://api.agekey.org/v1/oidc/create/par
```
### Headers
```
Content-Type: application/x-www-form-urlencoded
```
### Form fields
| Field | Value |
|-------|-------|
| `client_id` | Your AgeKey client ID |
| `client_secret` | Your AgeKey client secret |
| `scope` | `openid` |
| `response_type` | `none` |
| `type` | `age_verification` |
| `redirect_uri` | Your registered redirect URI |
| `state` | The state token you generated |
| `authorization_details` | **URL-encoded** JSON array from Step 2 |
```javascript
const axios = require('axios');
const qs = require('querystring');
async function createPushedAuthorizationRequest(verificationData, state, redirectUri) {
// Build authorization details
const authDetails = buildAuthorizationDetails(verificationData);
// Prepare form data
const formData = {
client_id: process.env.AGEKEY_CLIENT_ID,
client_secret: process.env.AGEKEY_CLIENT_SECRET,
scope: 'openid',
response_type: 'none',
type: 'age_verification',
redirect_uri: redirectUri,
state: state,
authorization_details: JSON.stringify(authDetails)
};
try {
const response = await axios.post(
'https://api.agekey.org/v1/oidc/create/par',
qs.stringify(formData),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return response.data;
// Returns: { request_uri: "urn:agekey:request:...", expires_in: 90 }
} catch (error) {
console.error('PAR request failed:', error.response?.data);
throw error;
}
}
```
```python
import requests
import json
from urllib.parse import urlencode
def create_pushed_authorization_request(verification_data, state, redirect_uri):
# Build authorization details
auth_details = build_authorization_details(verification_data)
# Prepare form data
form_data = {
'client_id': os.getenv('AGEKEY_CLIENT_ID'),
'client_secret': os.getenv('AGEKEY_CLIENT_SECRET'),
'scope': 'openid',
'response_type': 'none',
'type': 'age_verification',
'redirect_uri': redirect_uri,
'state': state,
'authorization_details': json.dumps(auth_details)
}
try:
response = requests.post(
'https://api.agekey.org/v1/oidc/create/par',
data=form_data,
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
response.raise_for_status()
return response.json()
# Returns: { "request_uri": "urn:agekey:request:...", "expires_in": 90 }
except requests.exceptions.RequestException as e:
print(f'PAR request failed: {e}')
raise
```
### Response
On success, you'll receive:
```json
{
"request_uri": "urn:agekey:request:AjcP1Yt7Np0",
"expires_in": 90
}
```
:::warning
**Important:** The `request_uri` expires in 90 seconds. The user must complete the next step within this time.
:::
---
## Step 5: Build the authorization URL
Now construct the URL to redirect the user to create their AgeKey.
:::tip Using an iframe?
If you're embedding the Create AgeKey flow in an iframe, you must include the `publickey-credentials-create` permission in the `allow` attribute:
```html
```
:::
### Required parameters
| Parameter | Value |
|-----------|-------|
| `client_id` | Your AgeKey client ID |
| `scope` | `openid` |
| `response_type` | `none` |
| `redirect_uri` | Same URI used in PAR request |
| `request_uri` | The `request_uri` from PAR response |
### Optional parameters
| Parameter | Value |
|-----------|-------|
| `language` | IETF BCP 47 tag (for example `pt-BR`) to set the AgeKey UI language for this redirect. When omitted, the UI follows the visitor's browser language preferences. See the [Create AgeKey authorization endpoint](/api/endpoints/create-agekey-auth#parameters) reference. |
| `theme` | Sets the visual theme of the AgeKey UI for this redirect. Accepts `light`, `dark`, or `system`. When omitted, the UI defaults to `light`. See the [Create AgeKey authorization endpoint](/api/endpoints/create-agekey-auth#parameters) reference. |
**Base URL:**
```
https://api.agekey.org/v1/oidc/create
```
```javascript
function buildCreateAgeKeyUrl(requestUri, redirectUri) {
const params = new URLSearchParams({
scope: 'openid',
response_type: 'none',
client_id: process.env.AGEKEY_CLIENT_ID,
redirect_uri: redirectUri,
request_uri: requestUri
});
// Optional: params.set('language', 'pt-BR');
return `https://api.agekey.org/v1/oidc/create?${params.toString()}`;
}
// Usage
app.post('/api/create-agekey', async (req, res) => {
const verificationData = req.body;
const state = generateState();
const redirectUri = 'https://yourapp.com/agekey/create-callback';
// Store state in session
req.session.agekey_create_state = state;
// Send PAR request
const parResponse = await createPushedAuthorizationRequest(
verificationData,
state,
redirectUri
);
// Build authorization URL
const authUrl = buildCreateAgeKeyUrl(parResponse.request_uri, redirectUri);
// Return URL to frontend for redirect
res.json({ redirect_url: authUrl });
});
```
```python
from urllib.parse import urlencode
def build_create_agekey_url(request_uri, redirect_uri):
params = {
'scope': 'openid',
'response_type': 'none',
'client_id': os.getenv('AGEKEY_CLIENT_ID'),
'redirect_uri': redirect_uri,
'request_uri': request_uri
}
return f"https://api.agekey.org/v1/oidc/create?{urlencode(params)}"
# Usage
@app.route('/api/create-agekey', methods=['POST'])
def create_agekey():
verification_data = request.json
state = generate_state()
redirect_uri = 'https://yourapp.com/agekey/create-callback'
# Store state in session
session['agekey_create_state'] = state
# Send PAR request
par_response = create_pushed_authorization_request(
verification_data,
state,
redirect_uri
)
# Build authorization URL
auth_url = build_create_agekey_url(
par_response['request_uri'],
redirect_uri
)
# Return URL to frontend for redirect
return jsonify({'redirect_url': auth_url})
```
---
## Step 6: Redirect User to AgeKey
Redirect the user's browser to the authorization URL you built in Step 5.
**Front end Code Example:**
```javascript
async function offerCreateAgeKey(verificationData) {
// Show "Save as AgeKey?" prompt to user
const userWantsToSave = await showSaveAgeKeyPrompt();
if (!userWantsToSave) {
return; // User declined
}
try {
// Call backend to get redirect URL
const response = await fetch('/api/create-agekey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(verificationData)
});
const data = await response.json();
// Redirect user to AgeKey service
window.location.href = data.redirect_url;
} catch (error) {
console.error('Failed to initiate AgeKey creation:', error);
showErrorMessage('Unable to create AgeKey. Please try again.');
}
}
```
---
## Step 7: Handle the Callback
After the user creates (or declines to create) their AgeKey, they'll be redirected back to your `redirect_uri`.
### Success response
If the user created an AgeKey:
```
https://yourapp.com/agekey/create-callback?state=abc123xyz789
```
No `id_token` or other data is returned. Success is indicated by the absence of an `error` parameter.
### Declined response
If the user declined to create an AgeKey:
```
https://yourapp.com/agekey/create-callback?
state=abc123xyz789&
error=access_denied
```
### Code example (server)
```javascript
app.get('/agekey/create-callback', (req, res) => {
const { state, error } = req.query;
// Verify state
const storedState = req.session.agekey_create_state;
if (state !== storedState) {
console.error('State mismatch - possible CSRF attack');
return res.status(400).send('Invalid state parameter');
}
// Clear state from session
delete req.session.agekey_create_state;
if (error) {
if (error === 'access_denied') {
// User declined to create AgeKey
console.log('User declined to create AgeKey');
return res.redirect('/dashboard?agekey_declined=true');
}
// Other error
console.error('AgeKey creation error:', error);
return res.redirect('/dashboard?agekey_error=true');
}
// Success! AgeKey was created
console.log('AgeKey created successfully');
res.redirect('/dashboard?agekey_created=true');
});
```
```python
@app.route('/agekey/create-callback')
def agekey_create_callback():
state = request.args.get('state')
error = request.args.get('error')
# Verify state
stored_state = session.get('agekey_create_state')
if state != stored_state:
print('State mismatch - possible CSRF attack')
return 'Invalid state parameter', 400
# Clear state from session
session.pop('agekey_create_state', None)
if error:
if error == 'access_denied':
# User declined to create AgeKey
print('User declined to create AgeKey')
return redirect('/dashboard?agekey_declined=true')
# Other error
print(f'AgeKey creation error: {error}')
return redirect('/dashboard?agekey_error=true')
# Success! AgeKey was created
print('AgeKey created successfully')
return redirect('/dashboard?agekey_created=true')
```
---
## Step 8: Show confirmation to user
After handling the callback, show an appropriate message to the user.
**Front end Code Example:**
```javascript
// On your dashboard/confirmation page
function showAgeKeyStatus() {
const params = new URLSearchParams(window.location.search);
if (params.get('agekey_created') === 'true') {
showSuccessMessage(
'AgeKey Created!',
'You can now use your AgeKey for quick age verification in the future.'
);
} else if (params.get('agekey_declined') === 'true') {
// User chose not to create AgeKey - this is fine
console.log('User declined AgeKey creation');
} else if (params.get('agekey_error') === 'true') {
showErrorMessage(
'Unable to create AgeKey',
'You can try again later from your account settings.'
);
}
}
```
This completes the Create AgeKey flow. Users now have a reusable, privacy-preserving credential they can use for quick age verification across your app.
---
// File: guides/mobile-apps
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
This guide covers best practices for integrating AgeKey flows into mobile applications. On the web, AgeKey flows are commonly embedded in iframes or opened in new windows, but mobile apps require webview components that support WebAuthn for passkey compatibility.
## Overview
AgeKey flows ([Use AgeKey](/guides/use-agekey), [Create AgeKey](/guides/create-agekey), and [Upgrade AgeKey](/guides/upgrade-agekey)) are web-based OIDC flows that require redirecting users to AgeKey service URLs. When integrating these flows into mobile applications, you have several options for displaying them, each with different capabilities and trade-offs. The key consideration is:
- **AgeKeys support**: Whether users can create and use AgeKeys (FIDO-based passkeys) for future verifications
AgeKeys require WebAuthn support, which isn't available in all mobile webview components. To enable AgeKeys for your users, you must use webview components that support WebAuthn.
## Mobile implementation methods
### Android options
Android provides three primary methods for displaying web content:
- **[Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs/overview/)** ⭐ **Recommended** - Opens the AgeKey flow URL in a customized Chrome browser tab that maintains your app's branding. This provides full browser capabilities while keeping users within your app's context. Custom Tabs share cookies and authentication state with Chrome, enabling seamless experiences.
- **[WebView](https://developer.android.com/reference/android/webkit/WebView)** - Embeds web content directly within your app using Android's native WebView component. While simple to implement, WebView has limited support for modern web standards and can't access certain browser features.
:::warning Not recommended
WebView doesn't support WebAuthn, which is required for AgeKeys. Users won't be able to create or use AgeKeys when flows are embedded using WebView. For the best user experience with full AgeKeys support, use Custom Tabs instead.
:::
- **[Trusted Web Activity (TWA)](https://developer.android.com/develop/ui/views/layout/webapps/trusted-web-activities)** - Displays web content in full-screen mode, primarily designed for Progressive Web Apps. TWAs require establishing a digital asset link between your app and your domain. When digital asset links aren't detected, TWA automatically falls back to Custom Tabs.
:::note
Trusted Web Activities require establishing digital asset links between your app and your domain. Since this adds complexity and TWA falls back to Custom Tabs when digital asset links aren't detected, use Custom Tabs directly for a simpler implementation.
:::
### iOS options
iOS provides three primary methods for displaying web content:
- **[ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession)** ⭐ **Recommended** - Designed specifically for secure authentication flows, this method presents web content in a system-managed browser view. It shares cookies with Safari and provides access to modern web features such as WebAuthn, making it ideal for AgeKey flows.
- **[SFSafariViewController](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller)** - Presents web content in a Safari-like interface that shares cookies and authentication state with Safari. This provides a familiar browsing experience while maintaining app context.
- **[WKWebView](https://developer.apple.com/documentation/webkit/wkwebview)** - Apple's modern web view component that embeds web content within your app. Similar to Android's WebView, WKWebView has limitations with certain web standards and can't access all browser features.
:::warning Not recommended
WKWebView doesn't support WebAuthn, which is required for AgeKeys. Users won't be able to create or use AgeKeys when flows are embedded using WKWebView. For the best user experience with full AgeKeys support, use ASWebAuthenticationSession instead.
:::
## Receiving flow results
Your mobile app needs to receive results after users complete the AgeKey flow. The recommended approach is to use your backend server as the `redirect_uri`, which validates and stores results before redirecting to your mobile app's deep link.
### Callback URL (recommended method)
:::tip Recommended approach
The recommended approach for all implementation methods is to use your backend server as the `redirect_uri`. When you build the AgeKey authorization URL, include your backend server URL as the `redirect_uri` parameter. After the AgeKey flow completes, it redirects to your backend with the results. Your backend then validates the results, stores them securely, and redirects to your mobile app's deep link.
:::
**Advantages of this approach:**
- Server-side validation ensures security and data integrity
- Results are stored securely on your backend
- Works with all implementation methods
- Standard pattern for mobile apps with backend validation
#### How callback URLs work
1. Register a deep link handler in your app (for example, `myapp://agekey/callback`, `myapp://agekey/create-callback`, or `myapp://agekey/upgrade-callback`)
2. Set your backend server URL as the `redirect_uri` when building the AgeKey authorization URL (for example, `https://yourapp.com/agekey/callback`)
3. The AgeKey service redirects to your backend after completion
4. Your backend validates the results, stores them securely, and redirects to your mobile app's deep link
5. Your app handles the deep link and receives the validated results
:::note
Redirects only occur when the AgeKey flow URL is opened directly in a browser or web view, not when embedded in an iframe.
:::
#### Callback URL parameters
When the AgeKey service redirects to your backend server, it includes query parameters or URL fragments relevant to the flow type:
**[Use AgeKey](/guides/use-agekey) flow:**
- `id_token`: The JWT token containing age threshold results (in URL fragment)
- `state`: The state parameter you sent (for CSRF protection)
**[Create AgeKey](/guides/create-agekey) flow:**
- `state`: The state parameter you sent (for CSRF protection)
- `error`: Present if the user declined or an error occurred (for example, `access_denied`)
**[Upgrade AgeKey](/guides/upgrade-agekey) flow:**
- `code`: Authorization code (present when all age thresholds are false, in URL fragment)
- `id_token`: The JWT token containing age threshold results (in URL fragment)
- `state`: The state parameter you sent (for CSRF protection)
Example backend callback URLs from AgeKey:
```
https://yourapp.com/agekey/callback#id_token=eyJhbGc...&state=abc123xyz789
https://yourapp.com/agekey/create-callback?state=abc123xyz789
https://yourapp.com/agekey/create-callback?state=abc123xyz789&error=access_denied
https://yourapp.com/agekey/upgrade-callback#code=abc123&id_token=eyJhbGc...&state=def456
```
After your backend validates and stores the results, it redirects to your mobile app's deep link:
```
myapp://agekey/callback?result=success
myapp://agekey/create-callback?result=success
myapp://agekey/create-callback?result=declined
myapp://agekey/upgrade-callback?result=success
```
#### Implementing callback URLs
Always use your backend server URL as the `redirect_uri` in your authorization URL when initiating AgeKey flows. The `redirect_uri` must be:
- An HTTPS URL: `https://yourapp.com/agekey/callback`
- Registered with AgeKey as an allowed redirect URI
Your backend should:
1. Receive the callback from AgeKey
2. Validate the results server-side, including verifying the JWT signature and checking state
3. Store the results securely
4. Redirect to your mobile app's deep link with the validation result
For detailed information about building authorization URLs and server-side validation, see the [Use AgeKey guide](/guides/use-agekey), [Create AgeKey guide](/guides/create-agekey), and [Upgrade AgeKey guide](/guides/upgrade-agekey).
## Method comparison
| Method | Platform | AgeKeys | Callback URL | Best For |
| ------------------------------ | -------- | ------- | ------------ | ---------------------------------------------- |
| **WebView** | Android | ❌ | ✅ | Not recommended (no AgeKeys support) |
| **Custom Tabs** | Android | ✅ | ✅ | Most use cases (recommended) |
| **Trusted Web Activity** | Android | ✅ | ✅ | Not recommended (requires digital asset links) |
| **WKWebView** | iOS | ❌ | ✅ | Not recommended (no AgeKeys support) |
| **ASWebAuthenticationSession** | iOS | ✅ | ✅ | Most use cases (recommended) |
| **SFSafariViewController** | iOS | ✅ | ✅ | Safari-like experience |
## Summary
When integrating AgeKey flows into mobile applications, use [Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs/overview/) for Android or [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) for iOS. Both provide full AgeKeys support via WebAuthn.
**Implementation steps:**
1. Register deep link handlers (URL schemes) for your app's callback URLs
2. Build AgeKey authorization URLs with your backend server URL as `redirect_uri`
3. Open the authorization URL using Custom Tabs (Android) or ASWebAuthenticationSession (iOS)
4. Your backend validates results server-side, then redirects to your app's deep link
5. Handle the deep link callback with validated results
For complete implementation details, see the [Use AgeKey guide](/guides/use-agekey), [Create AgeKey guide](/guides/create-agekey), and [Upgrade AgeKey guide](/guides/upgrade-agekey).
---
// File: guides/upgrade-agekey
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
## Overview
The Upgrade AgeKey flow allows users to upgrade their existing AgeKey with additional verification when age thresholds aren't met. This guide walks through implementing this flow step-by-step.
::::note Already familiar with OIDC?
This guide uses the OpenID Connect implicit flow (`response_type=id_token`) with the `agekey.upgrade` scope. When all age thresholds are false, both a `code` and an `id_token` are returned. The `id_token` contains the age thresholds, and the `code` is exchanged for an access token used to submit upgrade verification data. If you already know these flows, you mainly need the AgeKey-specific pieces: the `agekey.upgrade` scope and the upgrade endpoint flow.
::::
**Time to implement:** ~3 hours
---
## High-level flow overview
Before diving into code, it's important to understand what happens during the Upgrade AgeKey flow from start to finish.
### The user's journey
**1. User Needs Age Verification**
A user with an existing AgeKey attempts to access age-restricted content. They click "Verify with AgeKey" to verify their age.
**2. Redirect to AgeKey Service with Upgrade Scope**
When they click the button, your app redirects their browser to the AgeKey service with the `agekey.upgrade` scope. In the URL, you include information about what age thresholds you need to check (such as "Is this person 18 or older?").
**3. AgeKey Authentication**
The AgeKey service shows the user a prompt to authenticate with their passkey (similar to Touch ID, Face ID, or Windows Hello). This happens entirely on their device - no passwords needed, and the actual age information never leaves their device.
**4. Age Threshold Check**
Once authenticated, AgeKey checks the user's saved age information against the thresholds you requested. If all thresholds are false, it creates both a signed token containing the age thresholds and an authorization code.
**5. Return to Your App with Code and Token**
When all age thresholds are false, the user's browser is redirected back to your app with both a `code` parameter and an `id_token` in the URL. The `id_token` contains the age threshold results, and the `code` can be exchanged for an access token.
**6. Server validation**
Your server receives the `id_token` and performs critical security checks:
- Is the signature valid? (Proves it really came from AgeKey)
- Is it expired? (Prevents replay attacks)
- Does the audience match? (Ensures it was meant for your app)
- Do the security tokens match? (Prevents CSRF attacks)
**7. Extract Age Thresholds**
Once validated, your app extracts the age thresholds from the token. If all thresholds are false, you can offer the user the option to upgrade their AgeKey.
**8. Exchange Code for Access Token**
If the user wants to upgrade, your server exchanges the `code` for an access token by using Basic authentication with your client credentials.
**9. Perform Additional Verification**
You perform another verification method (ID scan, credit card verification, or other valid methods) to gather additional age verification data.
**10. Submit Upgrade Request**
Your server sends the upgrade verification data to AgeKey's upgrade endpoint by using the access token for authorization.
**11. Confirm Success**
Your app confirms the upgrade was successful and shows the user an appropriate message.
### Visual flow diagram
```mermaid
sequenceDiagram
actor User
participant App as Your App (Frontend)
participant Server as Your App (Server)
participant AgeKey as AgeKey Service
User->>App: Click "Verify with AgeKey"
App->>App: Generate security tokens (state, nonce)
App->>AgeKey: Redirect with agekey.upgrade scope + thresholds
Note over AgeKey: User authenticates with passkey
AgeKey->>AgeKey: Check age against thresholds
Note over AgeKey: All thresholds false
AgeKey->>App: Redirect with code + id_token {18: false}
App->>Server: Send token + code for validation
Server->>Server: Verify id_token signature
Server->>Server: Extract age thresholds
Note over Server: All thresholds false
Server->>User: Offer "Upgrade your AgeKey?"
User->>Server: Accept upgrade
Server->>AgeKey: POST /v1/oidc/use/token (exchange code)
AgeKey->>Server: Return access_token
User->>App: Perform additional verification
App->>Server: Send completed verification data
Server->>AgeKey: POST /v1/agekey/upgrade with token + verification data
AgeKey->>Server: Upgrade successful
Server->>App: Success!
App->>User: "AgeKey upgraded successfully"
```
### Key technical concepts
**OpenID Implicit Flow with Upgrade Scope**
This flow uses the OpenID Connect "implicit flow," but with the `agekey.upgrade` scope added. When all age thresholds are false, both a `code` and an `id_token` are returned in the URL.
**Age Thresholds in Token**
The `id_token` contains the age threshold results as boolean values. When all requested thresholds are false, this triggers the upgrade flow.
**Code Exchange Flow**
When all age thresholds are false, AgeKey returns a `code` parameter in addition to the `id_token`. This code must be exchanged for an access token by using your client credentials before submitting the upgrade request.
**Two-Step Verification**
The upgrade flow requires two separate verification steps:
1. Initial AgeKey verification that determines thresholds aren't met (shown in the `id_token`)
2. Additional verification method (ID scan, credit card, or other valid methods) to gather new age data for the upgrade
**Security Tokens**
- **State**: A random value you generate to prevent CSRF attacks. You check that the same value comes back.
- **Nonce**: Another random value to prevent replay attacks. It's embedded in the signed token.
**JWT Validation**
The `id_token` is a JSON Web Token (JWT) signed by AgeKey. Your server must verify this signature by using AgeKey's public keys to ensure it wasn't tampered with.
**Server-to-server communication**
The code exchange and upgrade request happen entirely server-to-server. The user's browser is never involved in transmitting authentication tokens or verification details.
### What you'll need to build
1. **Front end Component**: A button/link that redirects users to AgeKey with `agekey.upgrade` scope
2. **Callback Page**: A page that receives the user when they return from AgeKey with code and token
3. **Server validation**: Server-side code to verify the token and extract age threshold results
4. **Code Exchange Handler**: Server-side code to exchange the code for an access token
5. **Additional Verification**: Logic to perform a second verification method after obtaining the token
6. **Upgrade Request Handler**: Server-side code to submit upgrade data to AgeKey's upgrade endpoint
7. **Access Control Logic**: Code to offer upgrade when all thresholds are false
8. **User Experience Flow**: UI to offer AgeKey upgrade and show confirmation messages
### When to offer AgeKey upgrade
:::tip Best Practices
- ✅ **After failed threshold check**: Offer when `id_token` shows all thresholds as false
- ✅ **Make it optional**: Never force users to upgrade their AgeKey
- ✅ **Explain the benefit**: "Upgrade your AgeKey to meet the age requirement for this content"
- ✅ **Have verification method ready**: Ensure you have an additional verification method available before offering upgrade
- ❌ **Don't offer unnecessarily**: Only offer when age thresholds are actually not met
:::
Now you can implement each piece step by step.
---
## Step 1: Prepare your request parameters
Before redirecting the user, you need to prepare several parameters. These are sent as query parameters to the AgeKey authorization endpoint.
### Required parameters
| Parameter | What it's | Example |
|-----------|------------|---------|
| `client_id` | Your AgeKey client ID (provided by AgeKey) | `your-app-id-123` |
| `redirect_uri` | Where users return after verification (must be pre-registered) | `https://yourapp.com/agekey/callback` |
| `scope` | Always set to `openid agekey.upgrade` | `openid agekey.upgrade` |
| `response_type` | Always set to `id_token` (implicit flow) | `id_token` |
| `state` | Random token you generate for CSRF protection | `abc123xyz789` |
| `nonce` | Random value for replay protection | `nonce456def` |
| `claims` | URL encoded JSON string specifying age thresholds to check | See below |
### The claims parameter
The `claims` parameter tells AgeKey which age thresholds to verify. It must be a **URL-encoded JSON object**. For complete details on the claims structure, see the [Use AgeKey API Reference](/api/endpoints/use-agekey#claims-parameter).
**Example 1 - Check if user is 18 or older**
```json
{
"age_thresholds": [18]
}
```
**Example 2 - Check if user is 18 or older by an ID scan with face match scan performed**
```json
{
"age_thresholds": [18],
"allowed_methods": ["id_doc_scan"],
"overrides": {
"id_doc_scan": {
"attributes": {
"face_match_performed": true
}
}
}
}
```
---
## Step 2: Generate security tokens
:::tip Using an OIDC Library?
If you're using an OIDC library (recommended), it can typically handle `state` and `nonce` generation and validation automatically. Check your library's documentation to confirm it handles these security tokens - you might not need to implement this step manually.
:::
If you're implementing the OIDC flow manually (without a library), you need to generate random values for `state` and `nonce`. Store both values in your session so you can verify them when the user returns.
**Code Example:**
```javascript
function generateRandomString(length) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
const state = generateRandomString(16);
const nonce = generateRandomString(16);
// Store both state and nonce in session
sessionStorage.setItem('agekey_upgrade_state', state);
sessionStorage.setItem('agekey_upgrade_nonce', nonce);
```
```python
import secrets
state = secrets.token_urlsafe(16)
nonce = secrets.token_urlsafe(16)
# Store both state and nonce in session (example using Flask)
session['agekey_upgrade_state'] = state
session['agekey_upgrade_nonce'] = nonce
```
---
## Step 3: Build the authorization URL
Construct the URL to redirect the user to AgeKey.
:::tip UI language (`language`)
Upgrade uses the same **`/v1/oidc/use`** authorization URL as the standard Use flow. You can append **`language`** with an IETF BCP 47 tag (for example `params.set('language', 'pt-BR')`) to set the AgeKey UI language for this redirect. When omitted, the UI follows the visitor's browser language preferences. See the [Use AgeKey authorization endpoint](/api/endpoints/use-agekey#parameters) reference.
:::
:::tip Using an iframe?
If you're embedding the Upgrade AgeKey flow in an iframe, you must include the `publickey-credentials-get` permission in the `allow` attribute:
```html
```
:::
**Base URL:**
```
https://api.agekey.org/v1/oidc/use
```
**Full Example URL:**
```
https://api.agekey.org/v1/oidc/use?
scope=openid%20agekey.upgrade&
response_type=id_token&
client_id=your-client-id&
redirect_uri=https%3A%2F%2Fyourapp.com%2Fagekey%2Fcallback&
state=abc123xyz789&
nonce=nonce456def&
claims=%7B%22age_thresholds%22%3A%5B18%5D%7D
```
**Code Example:**
```javascript
const params = new URLSearchParams({
scope: 'openid agekey.upgrade',
response_type: 'id_token',
client_id: 'your-client-id',
redirect_uri: 'https://yourapp.com/agekey/callback',
state: state,
nonce: nonce,
claims: JSON.stringify({ age_thresholds: [18] })
});
// Optional: params.set('language', 'pt-BR');
const authUrl = `https://api.agekey.org/v1/oidc/use?${params.toString()}`;
// Redirect user
window.location.href = authUrl;
```
```python
from urllib.parse import urlencode
params = {
'scope': 'openid agekey.upgrade',
'response_type': 'id_token',
'client_id': 'your-client-id',
'redirect_uri': 'https://yourapp.com/agekey/callback',
'state': state,
'nonce': nonce,
'claims': json.dumps({'age_thresholds': [18]})
}
auth_url = f"https://api.agekey.org/v1/oidc/use?{urlencode(params)}"
return redirect(auth_url)
```
---
## Step 4: Handle the Callback
After the user completes verification, they'll be redirected back to your `redirect_uri` with results in the URL fragment.
### When all thresholds are false
If all age thresholds are false, the callback includes both a `code` and an `id_token`:
**Example Callback URL:**
```
https://yourapp.com/agekey/callback#
code=abc123xyz789&
id_token=eyJhbGc...long-jwt-string...&
state=def456uvw012
```
The `id_token` contains the age thresholds in its claims, and the `code` is used to obtain an access token for the upgrade request.
### When thresholds are met
If any threshold is met, only the `id_token` is returned (standard Use AgeKey flow):
**Example Callback URL:**
```
https://yourapp.com/agekey/callback#
id_token=eyJhbGc...long-jwt-string...&
state=def456uvw012
```
### Extract the token and code (front end)
**Code Example (JavaScript):**
```javascript
// On your callback page
function handleAgeKeyUpgradeCallback() {
// Get parameters from the URL fragment
const params = new URLSearchParams(window.location.hash.slice(1));
const idToken = params.get('id_token');
const code = params.get('code');
const returnedState = params.get('state');
const error = params.get('error');
// Check for errors
if (error) {
console.error('AgeKey error:', error);
// Handle error appropriately
return;
}
// Verify state matches
const storedState = sessionStorage.getItem('agekey_upgrade_state');
if (returnedState !== storedState) {
console.error('State mismatch - possible CSRF attack');
return;
}
// Send token and code to server for validation
fetch('/api/validate-agekey-upgrade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id_token: idToken,
code: code // May be null if thresholds are met
})
})
.then(response => response.json())
.then(data => {
if (data.verified) {
// Check if upgrade is needed (all thresholds false)
if (data.requires_upgrade && data.code) {
// Offer upgrade option
showUpgradeOption(data.age_thresholds, data.code);
} else {
// Age verification successful!
console.log('Age thresholds passed:', data.age_thresholds);
}
}
});
}
// Run when page loads
handleAgeKeyUpgradeCallback();
```
---
## Step 5: Validate the ID token (server)
:::danger Critical Security Requirement
Always validate the `id_token` in your server, never trust client-side validation alone.
:::
### What you need to validate
1. **Signature**: Verify the JWT is signed by AgeKey
2. **Issuer**: Check `iss` claim equals `https://api.agekey.org/v1/oidc/use`
3. **Audience**: Check `aud` claim contains your `client_id`
4. **Expiration**: Check `exp` claim (token not expired)
5. **Nonce**: Check `nonce` matches what you sent
### Using an OIDC library (recommended)
:::tip Best Practice
Using a certified OIDC library handles all the complex JWT validation automatically and reduces the risk of security vulnerabilities.
:::
```javascript
const { Issuer, generators } = require('openid-client');
// One-time setup
const ageKeyIssuer = await Issuer.discover('https://api.agekey.org/v1/oidc/use');
const client = new ageKeyIssuer.Client({
client_id: 'your-client-id',
redirect_uris: ['https://yourapp.com/agekey/callback'],
response_types: ['id_token'],
});
// Validate the token
app.post('/api/validate-agekey-upgrade', async (req, res) => {
const { id_token, code } = req.body;
const nonce = req.session.agekey_upgrade_nonce; // Retrieve stored nonce
try {
const tokenSet = await client.callback(
'https://yourapp.com/agekey/callback',
{ id_token },
{ nonce }
);
const claims = tokenSet.claims();
// Extract age threshold results
const ageThresholds = claims.age_thresholds;
// Example: { "18": false }
// Check if all thresholds are false (requires upgrade)
const allThresholdsFalse = Object.values(ageThresholds).every(value => value === false);
if (allThresholdsFalse && code) {
// Store code for later use in upgrade flow
req.session.agekey_upgrade_code = code;
return res.json({
verified: true,
age_thresholds: ageThresholds,
requires_upgrade: true,
code: code
});
}
// Thresholds met, standard flow
res.json({
verified: true,
age_thresholds: ageThresholds,
requires_upgrade: false
});
} catch (error) {
console.error('Token validation failed:', error);
res.status(400).json({ verified: false });
}
});
```
```python
from jose import jwt
import requests
# Fetch JWKS (do this once and cache)
jwks_uri = 'https://api.agekey.org/.well-known/jwks.json'
jwks = requests.get(jwks_uri).json()
@app.route('/api/validate-agekey-upgrade', methods=['POST'])
def validate_agekey_upgrade():
id_token = request.json['id_token']
code = request.json.get('code')
nonce = session.get('agekey_upgrade_nonce')
try:
# Decode and validate
claims = jwt.decode(
id_token,
jwks,
algorithms=['RS256'],
audience='your-client-id',
issuer='https://api.agekey.org/v1/oidc/use',
options={'verify_nonce': True}
)
# Verify nonce
if claims['nonce'] != nonce:
return jsonify({'verified': False}), 400
# Extract age threshold results
age_thresholds = claims['age_thresholds']
# Example: { "18": False }
# Check if all thresholds are false (requires upgrade)
all_thresholds_false = all(value == False for value in age_thresholds.values())
if all_thresholds_false and code:
# Store code for later use in upgrade flow
session['agekey_upgrade_code'] = code
return jsonify({
'verified': True,
'age_thresholds': age_thresholds,
'requires_upgrade': True,
'code': code
})
# Thresholds met, standard flow
return jsonify({
'verified': True,
'age_thresholds': age_thresholds,
'requires_upgrade': False
})
except Exception as e:
print(f'Token validation failed: {e}')
return jsonify({'verified': False}), 400
```
---
## Step 6: Check if upgrade is needed
After validating the token, check if all age thresholds are false. If they're, and a `code` was provided, offer the user the option to upgrade their AgeKey.
**Code Example (JavaScript):**
```javascript
function showUpgradeOption(ageThresholds, code) {
// Show user-friendly message
const message = `Your AgeKey doesn't meet the age requirements. ` +
`Would you like to upgrade it with additional verification?`;
const userWantsToUpgrade = confirm(message);
if (userWantsToUpgrade) {
// Initiate upgrade flow
initiateUpgrade(code);
} else {
// User declined, show alternative verification options
showAlternativeVerification();
}
}
```
---
## Step 7: Exchange code for access token
If the user wants to upgrade their AgeKey, exchange the authorization code for an access token. This token can be used to authorize the upgrade request.
### Endpoint
```
POST https://api.agekey.org/v1/oidc/use/token
```
### Headers
```
Authorization: Basic
Content-Type: application/x-www-form-urlencoded
```
### Form fields
| Field | Value |
|-------|-------|
| `grant_type` | `authorization_code` |
| `code` | The code received from the callback |
```javascript
const axios = require('axios');
const qs = require('querystring');
async function exchangeCodeForToken(code) {
const clientId = process.env.AGEKEY_CLIENT_ID;
const clientSecret = process.env.AGEKEY_CLIENT_SECRET;
// Create Basic auth header
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const formData = {
grant_type: 'authorization_code',
code: code
};
try {
const response = await axios.post(
'https://api.agekey.org/v1/oidc/use/token',
qs.stringify(formData),
{
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return response.data;
// Returns: { access_token: "...", token_type: "Bearer", expires_in: 10800 }
} catch (error) {
console.error('Token exchange failed:', error.response?.data);
throw error;
}
}
// Usage in your upgrade endpoint
app.post('/api/initiate-upgrade', async (req, res) => {
const code = req.session.agekey_upgrade_code;
if (!code) {
return res.status(400).json({ error: 'No authorization code found' });
}
try {
const tokenResponse = await exchangeCodeForToken(code);
// Store token for upgrade request
req.session.agekey_upgrade_token = tokenResponse.access_token;
// Clear code from session
delete req.session.agekey_upgrade_code;
res.json({
success: true,
message: 'Ready for additional verification'
});
} catch (error) {
console.error('Failed to exchange code for token:', error);
res.status(500).json({ error: 'Failed to initiate upgrade' });
}
});
```
```python
import requests
import base64
from urllib.parse import urlencode
def exchange_code_for_token(code):
client_id = os.getenv('AGEKEY_CLIENT_ID')
client_secret = os.getenv('AGEKEY_CLIENT_SECRET')
# Create Basic auth header
credentials = base64.b64encode(
f'{client_id}:{client_secret}'.encode()
).decode()
form_data = {
'grant_type': 'authorization_code',
'code': code
}
try:
response = requests.post(
'https://api.agekey.org/v1/oidc/use/token',
data=form_data,
headers={
'Authorization': f'Basic {credentials}',
'Content-Type': 'application/x-www-form-urlencoded'
}
)
response.raise_for_status()
return response.json()
# Returns: { "access_token": "...", "token_type": "Bearer", "expires_in": 10800 }
except requests.exceptions.RequestException as e:
print(f'Token exchange failed: {e}')
raise
# Usage in your upgrade endpoint
@app.route('/api/initiate-upgrade', methods=['POST'])
def initiate_upgrade():
code = session.get('agekey_upgrade_code')
if not code:
return jsonify({'error': 'No authorization code found'}), 400
try:
token_response = exchange_code_for_token(code)
# Store token for upgrade request
session['agekey_upgrade_token'] = token_response['access_token']
# Clear code from session
session.pop('agekey_upgrade_code', None)
return jsonify({
'success': True,
'message': 'Ready for additional verification'
})
except Exception as e:
print(f'Failed to exchange code for token: {e}')
return jsonify({'error': 'Failed to initiate upgrade'}), 500
```
### Response
On success, you'll receive:
```json
{
"access_token": "eyJhbGc...",
"token_type": "Bearer",
"expires_in": 10800
}
```
:::warning
**Important:** The `access_token` expires in 10800 seconds (3 hours). You should use it promptly for the upgrade request.
:::
---
## Step 8: Perform additional verification
At this point, you should perform another verification method to gather the age verification data that can be used to upgrade the AgeKey. This can be done before or after exchanging the code, depending on your UX flow.
For example:
- If you already performed ID verification before initiating the upgrade, use that verification data
- If you haven't yet, prompt the user to complete ID verification now
After successful verification, you should have:
- Verification method used (for example, `id_doc_scan`, `payment_card_network`)
- Age information (date of birth, age in years, or minimum age)
- Timestamp of verification
- Unique verification ID (you generate this)
### Build authorization details
The authorization details is a JSON array containing your verification result. This is what you'll send to AgeKey for the upgrade. For complete details on the authorization details structure, see the [Create AgeKey PAR API Reference](/api/endpoints/create-agekey-par#authorization-details-format).
---
## Step 9: Submit upgrade request
Now submit the upgrade request to AgeKey's upgrade endpoint by using the access token you obtained.
### Endpoint
```
POST https://api.agekey.org/v1/agekey/upgrade
```
### Headers
```
Authorization: Bearer
Content-Type: application/json
```
### Body
The request body should contain the `authorization_details` in the same format as the Create AgeKey flow:
```json
{
"authorization_details": [
{
"type": "age_verification",
"method": "",
"age": { },
"verified_at": "",
"verification_id": "",
"attributes": { },
"provenance": ""
}
]
}
```
```javascript
const axios = require('axios');
async function submitUpgradeRequest(accessToken, verificationData) {
// Build authorization details
const authDetails = buildAuthorizationDetails(verificationData);
try {
const response = await axios.post(
'https://api.agekey.org/v1/agekey/upgrade',
{
authorization_details: authDetails
},
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
return response.data;
// Returns: "OK"
} catch (error) {
console.error('Upgrade request failed:', error.response?.data);
throw error;
}
}
// Usage in your upgrade submit endpoint
app.post('/api/upgrade-agekey', async (req, res) => {
const accessToken = req.session.agekey_upgrade_token;
const verificationData = req.body; // Contains your additional verification data
if (!accessToken) {
return res.status(400).json({ error: 'No access token found' });
}
try {
const result = await submitUpgradeRequest(accessToken, verificationData);
// Clear token from session
delete req.session.agekey_upgrade_token;
res.json({
success: true,
message: 'AgeKey upgraded successfully'
});
} catch (error) {
console.error('Failed to submit upgrade request:', error);
res.status(500).json({ error: 'Failed to upgrade AgeKey' });
}
});
```
```python
import requests
import base64
import json
def submit_upgrade_request(access_token, verification_data):
# Build authorization details
auth_details = build_authorization_details(verification_data)
try:
response = requests.post(
'https://api.agekey.org/v1/agekey/upgrade',
json={
'authorization_details': auth_details
},
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
)
response.raise_for_status()
return response.text
# Returns: "OK"
except requests.exceptions.RequestException as e:
print(f'Upgrade request failed: {e}')
raise
# Usage in your upgrade submit endpoint
@app.route('/api/upgrade-agekey', methods=['POST'])
def upgrade_agekey():
access_token = session.get('agekey_upgrade_token')
verification_data = request.json # Contains your additional verification data
if not access_token:
return jsonify({'error': 'No access token found'}), 400
try:
result = submit_upgrade_request(access_token, verification_data)
# Clear token from session
session.pop('agekey_upgrade_token', None)
return jsonify({
'success': True,
'message': 'AgeKey upgraded successfully'
})
except Exception as e:
print(f'Failed to submit upgrade request: {e}')
return jsonify({'error': 'Failed to upgrade AgeKey'}), 500
```
### Response
On success, you'll receive a 200 status code:
```json
"OK"
```
---
## Step 10: Show confirmation to user
After handling the upgrade request, show an appropriate message to the user.
**Front end Code Example:**
```javascript
async function initiateUpgrade(code) {
try {
// Exchange code for token
const exchangeResponse = await fetch('/api/initiate-upgrade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code })
});
if (!exchangeResponse.ok) {
throw new Error('Failed to initiate upgrade');
}
// Perform additional verification (ID scan, etc.)
const verificationData = await performAdditionalVerification();
// Submit upgrade request
const upgradeResponse = await fetch('/api/upgrade-agekey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(verificationData)
});
const result = await upgradeResponse.json();
if (result.success) {
showSuccessMessage(
'AgeKey Upgraded!',
'Your AgeKey has been successfully upgraded. You can now use it for age-restricted content.'
);
} else {
showErrorMessage(
'Unable to upgrade AgeKey',
'Something went wrong during the upgrade process. Please try again later.'
);
}
} catch (error) {
console.error('Upgrade failed:', error);
showErrorMessage(
'Upgrade Failed',
'Unable to complete the upgrade. Please try again.'
);
}
}
```
This completes the Upgrade AgeKey flow. Users can now upgrade their AgeKey with additional verification when their existing AgeKey doesn't meet required age thresholds.
---
// File: guides/use-agekey
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
## Overview
The Use AgeKey flow allows users with an existing AgeKey to quickly verify their age. This guide walks through implementing this flow step-by-step.
::::note Already familiar with OIDC?
This guide uses the OpenID Connect implicit flow (`response_type=id_token`). If you already know this flow, you mainly need the AgeKey-specific piece: the `claims` parameter format (see the [Use AgeKey API Reference](/api/endpoints/use-agekey#claims-parameter)). The rest follows the standard OIDC pattern to spec.
::::
**Time to implement:** ~2 hours
---
## High-level flow overview
Before diving into code, it's important to understand what happens during the Use AgeKey flow from start to finish.
### The user's journey
**1. User Needs Age Verification**
Imagine a user visiting your app and needs to prove they're old enough to access certain content (for example, 13+ or 18+ content). Instead of filling out forms or uploading IDs, they see a "Verify with AgeKey" button.
**2. Redirect to AgeKey Service**
When they click the button, your app redirects their browser to the AgeKey service. In the URL, you include information about what age thresholds you need to check (such as "Is this person 13 or older?" and "Is this person 18 or older?").
**3. AgeKey Authentication**
The AgeKey service shows the user a prompt to authenticate with their passkey (similar to Touch ID, Face ID, or Windows Hello). This happens entirely on their device - no passwords needed, and the actual age information never leaves their device.
**4. Age Threshold Check**
Once authenticated, AgeKey checks the user's saved age information against the thresholds you requested. It creates a signed token containing simple yes or no answers: "Yes, they're 13+" and "No, they're not 18+," for example.
**5. Return to Your App**
The user's browser is redirected back to your app with this signed token in the URL. Your app receives the token but hasn't verified it yet - it's just a claim at this point.
:::note Use or Create AgeKey dialog
Whether this dialog is displayed is controlled by the **`can_create`** request parameter. When `can_create` is present and set to `true`, users see both "Use AgeKey" and "Create AgeKey"; otherwise only "Use AgeKey" is shown. If the user chooses to create an AgeKey, they're redirected to your `redirect_uri` with `create_requested=true` (and no `id_token`). Your app should then send them to complete verification with another method (such as ID document scan). Once that verification succeeds, direct them to the [Create AgeKey](/guides/create-agekey) flow. This avoids forcing users who selected AgeKey by mistake to leave and pick a different verification method from scratch.
:::
**6. Server validation**
Your server receives the token and performs critical security checks:
- Is the signature valid? (Proves it really came from AgeKey)
- Is it expired? (Prevents replay attacks)
- Does the audience match? (Ensures it was meant for your app)
- Do the security tokens match? (Prevents CSRF attacks)
**7. Grant Access**
Once validated, your app can trust the age verification results. If the user passed the required thresholds, you grant them access to the appropriate content. If not, you show them age-restricted content notices.
### Visual flow diagram
```mermaid
sequenceDiagram
actor User
participant App as Your App (Frontend)
participant Server as Your App (Server)
participant AgeKey as AgeKey Service
User->>App: Click "Verify with AgeKey"
App->>App: Generate security tokens (state, nonce)
App->>AgeKey: Redirect with age thresholds [13, 18]
Note over AgeKey: User authenticates with passkey
AgeKey->>AgeKey: Check age against thresholds
AgeKey->>App: Redirect with signed token {13: true, 18: false}
App->>Server: Send token for validation
Server->>Server: Verify signature, Check expiration, Validate security tokens
alt Token Valid
Server->>App: Verification successful
App->>User: Grant appropriate access
else Token Invalid
Server->>App: Verification failed
App->>User: Show error message
end
```
### Key technical concepts
**OpenID Implicit Flow**
This flow uses the OpenID Connect "implicit flow," which means the age verification result comes back as a signed token (`id_token`) directly in the URL, rather than requiring a separate API call to exchange a code for a token.
**Age Thresholds, Not Exact Age**
For privacy, you only learn yes or no answers to age questions. If you ask "Is this person 18 or older?," you get back `true` or `false`, never the person's actual age or date of birth.
**Security Tokens**
- **State**: A random value you generate to prevent CSRF attacks. You check that the same value comes back.
- **Nonce**: Another random value to prevent replay attacks. It's embedded in the signed token.
**JWT Validation**
The `id_token` is a JSON Web Token (JWT) signed by AgeKey. Your server must verify this signature by using AgeKey's public keys to ensure it wasn't tampered with.
### What you'll need to build
1. **Front end Component**: A button/link that redirects users to AgeKey with appropriate parameters
2. **Callback Page**: A page that receives the user when they return from AgeKey
3. **Server validation**: Server-side code to verify the token and extract age threshold results
4. **Access Control Logic**: Code to grant or deny access based on the verified age thresholds
Now you can implement each piece step by step.
---
## Step 1: Prepare your request parameters
Before redirecting the user, you need to prepare several parameters. These are sent as query parameters to the AgeKey authorization endpoint.
### Required parameters
| Parameter | What it's | Example |
|-----------|------------|---------|
| `client_id` | Your AgeKey client ID (provided by AgeKey) | `your-app-id-123` |
| `redirect_uri` | Where users return after verification (must be pre-registered) | `https://yourapp.com/agekey/callback` |
| `scope` | Always set to `openid` | `openid` |
| `response_type` | Always set to `id_token` (implicit flow) | `id_token` |
| `state` | Random token you generate for CSRF protection | `abc123xyz789` |
| `nonce` | Random value for replay protection | `nonce456def` |
| `claims` | URL encoded JSON string specifying age thresholds to check | See below |
### Optional parameters
| Parameter | What it's | Example |
|-------------|-----------|---------|
| `can_create` | When present and `true`, users see both "Use AgeKey" and "Create AgeKey". When omitted or not `true`, only "Use AgeKey" is shown. | `true` |
### The claims parameter
The `claims` parameter tells AgeKey which age thresholds to verify. It must be a **URL-encoded JSON object**. For complete details on the claims structure, see the [Use AgeKey API Reference](/api/endpoints/use-agekey#claims-parameter).
**Example 1 - Check if user is 13 or older AND 18 or older**
```json
{
"age_thresholds": [13, 18]
}
```
**Example 2 - Check if user is 18 or older by an ID scan with face match scan performed**
```json
{
"age_thresholds": [18],
"allowed_methods": ["id_doc_scan"],
"overrides": {
"id_doc_scan": {
"attributes": {
"face_match_performed": true
}
}
}
}
```
---
## Step 2: Generate security tokens
:::tip Using an OIDC Library?
If you're using an OIDC library (recommended), it can typically handle `state` and `nonce` generation and validation automatically. Check your library's documentation to confirm it handles these security tokens - you might not need to implement this step manually.
:::
If you're implementing the OIDC flow manually (without a library), you need to generate random values for `state` and `nonce`. Store both values in your session so you can verify them when the user returns.
**Code Example (JavaScript):**
```javascript
function generateRandomString(length) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
const state = generateRandomString(16);
const nonce = generateRandomString(16);
// Store both state and nonce in session
sessionStorage.setItem('agekey_state', state);
sessionStorage.setItem('agekey_nonce', nonce);
```
**Code Example (Python):**
```python
import secrets
state = secrets.token_urlsafe(16)
nonce = secrets.token_urlsafe(16)
# Store both state and nonce in session (example using Flask)
session['agekey_state'] = state
session['agekey_nonce'] = nonce
```
---
## Step 3: Build the authorization URL
Construct the URL to redirect the user to AgeKey.
:::tip UI language (`language`)
To force a specific AgeKey UI language (instead of the visitor's browser default), append a **`language`** query parameter with an IETF BCP 47 tag (for example `params.set('language', 'pt-BR')` in JavaScript). See the [Use AgeKey authorization endpoint](/api/endpoints/use-agekey#parameters) reference for details.
:::
:::tip Using an iframe?
If you're embedding the Use AgeKey flow in an iframe, you must include the `publickey-credentials-get` permission in the `allow` attribute:
```html
```
:::
**Base URL:**
```
https://api.agekey.org/v1/oidc/use
```
**Full Example URL:**
```
https://api.agekey.org/v1/oidc/use?
scope=openid&
response_type=id_token&
client_id=your-client-id&
redirect_uri=https%3A%2F%2Fyourapp.com%2Fagekey%2Fcallback&
state=abc123xyz789&
nonce=nonce456def&
claims=%7B%22age_thresholds%22%3A%5B13%2C18%5D%7D
```
**Code Example (JavaScript):**
```javascript
const params = new URLSearchParams({
scope: 'openid',
response_type: 'id_token',
client_id: 'your-client-id',
redirect_uri: 'https://yourapp.com/agekey/callback',
state: state,
nonce: nonce,
claims: JSON.stringify({ age_thresholds: [13, 18] })
});
// Optional: params.set('can_create', 'true') to show both "Use AgeKey" and "Create AgeKey"
// Optional: params.set('language', 'pt-BR') for a specific UI language (IETF BCP 47)
const authUrl = `https://api.agekey.org/v1/oidc/use?${params.toString()}`;
// Redirect user
window.location.href = authUrl;
```
**Code Example (Python / Flask):**
```python
from urllib.parse import urlencode
params = {
'scope': 'openid',
'response_type': 'id_token',
'client_id': 'your-client-id',
'redirect_uri': 'https://yourapp.com/agekey/callback',
'state': state,
'nonce': nonce,
'claims': json.dumps({'age_thresholds': [13, 18]})
}
auth_url = f"https://api.agekey.org/v1/oidc/use?{urlencode(params)}"
return redirect(auth_url)
```
---
## Step 4: Handle the Callback
After the user completes verification, they'll be redirected back to your `redirect_uri` with the result in the URL fragment.
**Example Callback URL (success):**
```
https://yourapp.com/agekey/callback#
state=abc123xyz789&
id_token=eyJhbGc...long-jwt-string...
```
:::note Callback when user chose to create an AgeKey
When you sent `can_create=true` and the user was shown the use-or-create dialog and chose to create an AgeKey, they're redirected with `create_requested=true` and no `id_token`. Your app should send them to complete verification with another method (such as ID document scan). Once that verification succeeds, direct them to the [Create AgeKey](/guides/create-agekey) flow.
```
https://yourapp.com/agekey/callback#
state=abc123xyz789&
create_requested=true
```
:::
If the user clicks the back button or otherwise abandons the flow, the redirect includes `error=access_denied` (and `state`):
```
https://yourapp.com/agekey/callback#
state=abc123xyz789&
error=access_denied
```
| Parameter | When present | Meaning |
|-------------------|--------------|---------|
| `id_token` | Success | Signed JWT with age threshold results; validate on your server. |
| `create_requested`| When `can_create=true` was sent | Present and `true` when the user chose "Create AgeKey" in the use-or-create dialog. Send the user to complete verification with another method; after success, direct them to the Create AgeKey flow. Only returned if the request included `can_create=true`. |
| `state` | Always | Your CSRF token; verify it matches the value you sent. |
| `error` | On error | Error code from AgeKey (for example, `access_denied` when the user clicks back or abandons the flow); handle appropriately. |
### Extract the token (front end)
**Code Example (JavaScript):**
```javascript
// On your callback page
function handleAgeKeyCallback() {
// Get parameters from the URL fragment
const params = new URLSearchParams(window.location.hash.slice(1));
const idToken = params.get('id_token');
const returnedState = params.get('state');
const error = params.get('error');
const createRequested = params.get('create_requested') === 'true';
// Check for errors
if (error) {
console.error('AgeKey error:', error);
// Handle error appropriately
return;
}
// User chose to create an AgeKey from the use-or-create dialog
if (createRequested) {
// Send them to your verification flow (such as other methods). After they pass, redirect to Create AgeKey.
window.location.href = '/verify';
return;
}
// Verify state matches
const storedState = sessionStorage.getItem('agekey_state');
if (returnedState !== storedState) {
console.error('State mismatch - possible CSRF attack');
return;
}
// Send token to server for validation
fetch('/api/validate-agekey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id_token: idToken })
})
.then(response => response.json())
.then(data => {
if (data.verified) {
// Age verification successful!
console.log('Age thresholds passed:', data.age_thresholds);
}
});
}
// Run when page loads
handleAgeKeyCallback();
```
---
## Step 5: Validate the ID token (server)
:::danger Critical Security Requirement
Always validate the `id_token` in your server, never trust client-side validation alone.
:::
### What you need to validate
1. **Signature**: Verify the JWT is signed by AgeKey
2. **Issuer**: Check `iss` claim equals `https://api.agekey.org/v1/oidc/use`
3. **Audience**: Check `aud` claim contains your `client_id`
4. **Expiration**: Check `exp` claim (token not expired)
5. **Nonce**: Check `nonce` matches what you sent
### Using an OIDC library (recommended)
:::tip Best Practice
Using a certified OIDC library handles all the complex JWT validation automatically and reduces the risk of security vulnerabilities.
:::
```javascript
const { Issuer, generators } = require('openid-client');
// One-time setup
const ageKeyIssuer = await Issuer.discover('https://api.agekey.org/v1/oidc/use');
const client = new ageKeyIssuer.Client({
client_id: 'your-client-id',
redirect_uris: ['https://yourapp.com/agekey/callback'],
response_types: ['id_token'],
});
// Validate the token
app.post('/api/validate-agekey', async (req, res) => {
const { id_token } = req.body;
const nonce = req.session.agekey_nonce; // Retrieve stored nonce
try {
const tokenSet = await client.callback(
'https://yourapp.com/agekey/callback',
{ id_token },
{ nonce }
);
const claims = tokenSet.claims();
// Extract age threshold results
const ageThresholds = claims.age_thresholds;
// Example: { "13": true, "18": false }
// Grant appropriate access based on results
if (ageThresholds["13"]) {
// User is 13 or older
}
if (ageThresholds["18"]) {
// User is 18 or older
}
res.json({ verified: true, age_thresholds: ageThresholds });
} catch (error) {
console.error('Token validation failed:', error);
res.status(400).json({ verified: false });
}
});
```
```python
from jose import jwt
import requests
# Fetch JWKS (do this once and cache)
jwks_uri = 'https://api.agekey.org/.well-known/jwks.json'
jwks = requests.get(jwks_uri).json()
@app.route('/api/validate-agekey', methods=['POST'])
def validate_agekey():
id_token = request.json['id_token']
nonce = session.get('agekey_nonce')
try:
# Decode and validate
claims = jwt.decode(
id_token,
jwks,
algorithms=['RS256'],
audience='your-client-id',
issuer='https://api.agekey.org/v1/oidc/use',
options={'verify_nonce': True}
)
# Verify nonce
if claims['nonce'] != nonce:
return jsonify({'verified': False}), 400
# Extract age threshold results
age_thresholds = claims['age_thresholds']
# Example: { "13": True, "18": False }
return jsonify({
'verified': True,
'age_thresholds': age_thresholds
})
except Exception as e:
print(f'Token validation failed: {e}')
return jsonify({'verified': False}), 400
```
---
## Step 6: Act on the Results
The validated `id_token` contains the following claims:
| Claim | Type | Description |
|-------|------|-------------|
| `sub` | string | The **session ID** for this verification. See the important note below on logging this value. |
| `iss` | string | Token issuer (`https://api.agekey.org/v1/oidc/use`) |
| `aud` | array | Your `client_id` |
| `iat` | number | Issued-at timestamp (UNIX epoch) |
| `exp` | number | Expiration timestamp (UNIX epoch) |
| `nonce` | string | The nonce you sent in the request |
| `age_thresholds` | object | boolean results for each requested age threshold |
| `req_claims_hash` | string | SHA-256 hash of the claims you requested (for tamper detection) |
### Age threshold results
The `age_thresholds` claim contains a boolean value for each threshold you requested.
**Example Result:**
```json
{
"13": true,
"18": false
}
```
This means:
- User **is** 13 or older ✅
- User **isn't** 18 or older ❌
**Code Example:**
```javascript
if (ageThresholds["13"]) {
// Grant access to 13+ content
allowUserAccess();
}
if (ageThresholds["18"]) {
// Grant access to 18+ content
allowAdultContent();
} else {
// Show age-restricted notice
showRestrictionMessage();
}
```
### Logging the session ID
:::warning Important: log the session ID
The `sub` claim in the `id_token` is the **session ID** for this verification. You should persist this value in your system alongside the verification outcome.
If an age signal is later found to be fraudulent or invalid, AgeKey might need to identify which sessions were affected. The session ID is the key used for **invalidation auditing**; without it, there is no way to trace back which of your users' verifications were impacted by a revocation.
:::
```javascript
const claims = tokenSet.claims();
const sessionId = claims.sub;
const ageThresholds = claims.age_thresholds;
// Persist the session ID with the verification result
await db.verificationLogs.create({
agekey_session_id: sessionId,
age_thresholds: ageThresholds,
verified_at: new Date(),
user_id: currentUser.id,
});
```
```python
session_id = claims["sub"]
age_thresholds = claims["age_thresholds"]
# Persist the session ID with the verification result
db.verification_logs.insert({
"agekey_session_id": session_id,
"age_thresholds": age_thresholds,
"verified_at": datetime.utcnow(),
"user_id": current_user.id,
})
```
This completes the Use AgeKey flow. You can now gate content based on verified age thresholds without handling sensitive personal data.
---
// File: guides/ux-flow
## Overview
This guide covers the complete user experience journey for AgeKey integration, including when to present users with the ability to Create, Use, and Upgrade AgeKeys. Understanding the full flow helps you design a seamless experience that guides users through age verification while offering AgeKey as a convenient option.
---
## Who hosts each part of the experience?
AgeKey integration involves two distinct sets of user interfaces. Knowing which side owns each screen is the most important thing to internalize before you design the rest of your flow.
**Hosted by your app (you build and style these screens):**
- The initial verification method picker (showing AgeKey alongside your other verification methods such as ID scan, credit card, and facial estimation)
- Any non-AgeKey verification UI (typically rendered by your verification vendor inside your app)
- Error and retry UI when a non-AgeKey verification fails
- The "Offer AgeKey upgrade" prompt shown when a Use AgeKey result doesn't meet the requested age thresholds
- The "Offer Create or Upgrade AgeKey" prompt shown after a successful non-AgeKey verification
- The final "Grant access" or restricted-content screen
**Hosted by AgeKey on `agekey.org` (handled for you):**
- The "Use AgeKey or Create AgeKey" dialog (rendered by AgeKey when you pass `can_create=true` on the Use AgeKey request)
- The passkey authentication UI used to prove an existing AgeKey
- The passkey creation UI used when creating a new AgeKey, including upgrades
Whenever you redirect a user to a URL on `agekey.org`, AgeKey takes over the UI for that step. When the user finishes (or abandons) the step, AgeKey redirects the browser back to the `redirect_uri` you configured and your app resumes control. Your responsibility ends at the redirect to AgeKey and resumes when the user lands back on your `redirect_uri`.
---
## User journey overview
The AgeKey user experience is a unified flow that begins by presenting users with multiple verification options:
1. **Initial options** (your app): Users see verification options including AgeKey, ID scan, credit card, facial estimation, and other methods
2. **If user chooses AgeKey** (AgeKey-hosted): They're redirected to `agekey.org` and presented with "Use AgeKey" or "Create AgeKey" options
- **Use AgeKey**: User authenticates with their existing passkey on AgeKey. Results are returned to your `redirect_uri`. If age thresholds aren't met, your app offers an upgrade
- **Create AgeKey**: AgeKey redirects the user back to your `redirect_uri` with `create_requested=true`. Your app performs a non-AgeKey verification, then redirects back to AgeKey to create the passkey
3. **If user chooses traditional verification** (your app): Your app performs a non-AgeKey verification. If successful, your app offers to Create or Upgrade AgeKey before redirecting to AgeKey for passkey creation or upgrade
All paths converge to the same outcome: your app grants access to the requested content.
---
## When to present each flow
### 1. Use AgeKey flow
**When to present:**
- User selects AgeKey from the initial verification options in your app
- User has potentially created an AgeKey on a previous visit
**User experience:**
1. *(Your app)* User selects AgeKey from the verification options you display
2. *(Your app)* You redirect the user to `agekey.org` with `can_create=true`
3. *(AgeKey-hosted)* User sees the "Use AgeKey or Create AgeKey" dialog and chooses "Use AgeKey"
4. *(AgeKey-hosted)* User authenticates with their passkey
5. *(AgeKey-hosted)* AgeKey evaluates the user's age against your requested thresholds and redirects back to your `redirect_uri` with a signed `id_token`
6. *(Your app)* If thresholds are met, grant access; if not, your app offers an AgeKey upgrade
**Implementation:** See the [Use AgeKey guide](/guides/use-agekey) for detailed implementation steps.
:::tip Best Practice
Present AgeKey as one of the initial verification options in your own UI. AgeKey itself shows the "Use AgeKey or Create AgeKey" dialog after the redirect when you pass `can_create=true`, so users who picked AgeKey by mistake have an easy way to create one without restarting.
:::
### 2. Create AgeKey flow
**When to present:**
- User selects AgeKey from your initial options, sees the AgeKey-hosted Use-or-Create dialog, and chooses "Create AgeKey" (AgeKey redirects back to your app with `create_requested=true`)
- OR user completes non-AgeKey verification successfully in your app, then chooses "Create" from the Create-or-Upgrade prompt you display
**User experience:**
1. **Path 1 (from AgeKey selection)**: *(Your app)* offers AgeKey → *(AgeKey-hosted)* dialog → user chooses "Create AgeKey" → *(your app)* receives `create_requested=true` and runs a non-AgeKey verification → *(your app)* redirects to AgeKey to create the passkey → *(AgeKey-hosted)* user creates passkey → *(your app)* receives the redirect back and grants access
2. **Path 2 (from traditional verification)**: *(Your app)* runs a non-AgeKey verification → *(your app)* offers "Create or Upgrade" → user chooses "Create" → *(your app)* redirects to AgeKey to create the passkey → *(AgeKey-hosted)* user creates passkey → *(your app)* receives the redirect back and grants access
**Implementation:** See the [Create AgeKey guide](/guides/create-agekey) for detailed implementation steps.
:::tip Best Practice
- Always make AgeKey creation optional; never force users to create one
- The "Create or Upgrade" prompt is rendered by your app, not by AgeKey; design it to clearly explain the benefit
- The actual passkey creation UI is hosted by AgeKey, so you don't need to design it yourself
:::
### 3. Upgrade AgeKey flow
**When to present:**
- User used an AgeKey but the returned age thresholds aren't met, so your app shows an "Offer Upgrade" prompt
- OR user completes non-AgeKey verification successfully in your app, then chooses "Upgrade" from your Create-or-Upgrade prompt
**User experience:**
1. **Path 1 (from Use AgeKey)**: *(AgeKey-hosted)* user authenticates → *(your app)* receives the `id_token`, sees thresholds aren't met, and offers an upgrade → *(your app)* runs additional verification → *(your app)* redirects to AgeKey to upgrade the passkey → *(AgeKey-hosted)* user authenticates passkey to attach the new signal → *(your app)* receives redirect back and grants access
2. **Path 2 (from traditional verification)**: *(Your app)* runs a non-AgeKey verification → *(your app)* offers "Create or Upgrade" → user chooses "Upgrade" → *(your app)* redirects to AgeKey → *(AgeKey-hosted)* user authenticates passkey → *(your app)* receives redirect back and grants access
**Implementation:** See the [Upgrade AgeKey guide](/guides/upgrade-agekey) for detailed implementation steps.
:::tip Best Practice
- The "Offer Upgrade" prompt is hosted by your app; only show it when thresholds actually aren't met or when the user has just completed a non-AgeKey verification
- Make the upgrade optional; users can always decline and continue with traditional verification
:::
---
## Complete flow decision tree
The following diagram illustrates the complete unified user experience flow. Boxes are color-coded by who owns each screen:
- **Blue**: your app hosts the UI (you build and style it)
- **Purple**: AgeKey hosts the UI at `agekey.org` (handled for you; user is redirected back to your `redirect_uri` when done)
- **Green**: terminal success state in your app
- **Gray diamonds**: decision points
```mermaid
flowchart TD
Start([User needs age verification]) --> ShowOptions[Show verification options AgeKey / ID scan / Credit card / etc.]
ShowOptions --> UserSelects{User chooses}
UserSelects -->|AgeKey| AgeKeyDialog[Show Use AgeKey or Create AgeKey dialog]
AgeKeyDialog --> UserChooses{User chooses}
UserChooses -->|Use AgeKey| Authenticate[User authenticates with passkey]
Authenticate --> ThresholdsMet{Age thresholds met?}
ThresholdsMet -->|Yes| GrantAccess[Grant access to content]
ThresholdsMet -->|No| OfferUpgrade[Offer AgeKey upgrade]
OfferUpgrade --> UserAcceptsUpgrade{User accepts upgrade?}
UserAcceptsUpgrade -->|Yes| AdditionalVerify[Perform additional verification ID scan / Credit card / etc.]
AdditionalVerify --> UpgradePasskey[User authenticates passkey to upgrade AgeKey]
UpgradePasskey --> GrantAccess
UserAcceptsUpgrade -->|No| TraditionalVerify[Use traditional verification]
TraditionalVerify --> GrantAccess
UserChooses -->|Create AgeKey| VerifyProcess
UserSelects -->|Traditional verification| VerifyProcess[Perform non-AgeKey verification ID scan / Credit card / Facial estimation / etc.]
VerifyProcess --> VerifySuccess{Verification successful?}
VerifySuccess -->|No| ShowErrorAndRetry[Show error message and allow user to retry]
ShowErrorAndRetry --> VerifyProcess
VerifySuccess -->|Yes| CreateOrOffer{User came from Create AgeKey?}
CreateOrOffer -->|Yes| CreatePasskey[User creates passkey]
CreateOrOffer -->|No| OfferCreateOrUpgrade[Offer Create or Upgrade AgeKey]
OfferCreateOrUpgrade --> UserChoosesCreateOrUpgrade{User chooses}
UserChoosesCreateOrUpgrade -->|Create| CreatePasskey
UserChoosesCreateOrUpgrade -->|Upgrade| UpgradePasskey
UserChoosesCreateOrUpgrade -->|Decline| GrantAccess
CreatePasskey --> GrantAccess
subgraph Legend [Legend]
direction LR
LegendDev[Hosted by your app]
LegendAgeKey[Hosted by AgeKey on agekey.org]
LegendDone[Final state in your app]
end
classDef devUI fill:#1e3a8a,stroke:#3b82f6,stroke-width:2px,color:#fff
classDef agekeyUI fill:#6b21a8,stroke:#c084fc,stroke-width:2px,color:#fff
classDef success fill:#166534,stroke:#22c55e,stroke-width:2px,color:#fff
class ShowOptions,VerifyProcess,ShowErrorAndRetry,OfferUpgrade,AdditionalVerify,TraditionalVerify,OfferCreateOrUpgrade,LegendDev devUI
class AgeKeyDialog,Authenticate,UpgradePasskey,CreatePasskey,LegendAgeKey agekeyUI
class GrantAccess,LegendDone success
```
---
## User experience best practices
### 1. Progressive disclosure
Don't overwhelm users with all options at once. Present flows contextually:
- **Initial screen (your app)**: Show verification options, including AgeKey, ID scan, and credit card, so users can choose
- **After selecting AgeKey (AgeKey-hosted)**: AgeKey shows both "Use AgeKey" and "Create AgeKey" options automatically when you pass `can_create=true` (no UI work required from you)
- **After traditional verification (your app)**: Offer both "Create" and "Upgrade" options to maximize user choice
- **After Use AgeKey fails thresholds (your app)**: Offer upgrade option with fallback to traditional verification
### 2. Clear messaging
Use clear, benefit-focused language:
- ✅ "Verify with AgeKey - Fast and secure"
- ✅ "Save as AgeKey for faster verification next time"
- ✅ "Upgrade your AgeKey to access this content"
- ❌ "Create AgeKey" (without context)
- ❌ "Upgrade AgeKey" (without explaining why)
### 3. Graceful fallbacks
Always provide alternatives:
- If AgeKey creation fails → User can continue without it
- If AgeKey verification fails → Offer traditional verification
- If upgrade is declined → Allow traditional verification
### 4. Error handling
Handle errors gracefully:
- Network failures → Retry option
- Expired tokens → Regenerate and retry
- User cancellation → Return to previous step
- Verification failures → Clear error messages with next steps
---
## Next steps
Now that you understand the complete UX flow:
1. **Review individual guides** for detailed implementation:
- [Use AgeKey](/guides/use-agekey)
- [Create AgeKey](/guides/create-agekey)
- [Upgrade AgeKey](/guides/upgrade-agekey)
2. **Plan your implementation** using the decision tree in the preceding section
3. **Test thoroughly** with different user scenarios
5. **Monitor and iterate** based on user feedback
For mobile app integration, see the [Mobile Apps guide](/guides/mobile-apps) for platform-specific considerations.
---
// File: intro
# Privacy-preserving age verification
AgeKey enables secure, privacy-first age verification without exposing personal data. Built for developers who care about user privacy.
## Who is this for?
AgeKey is designed for **Age Assurance Providers** and platforms that orchestrate multiple verification methods.
:::note For other use cases
If you need to implement age verification into a game or app, consider using [OpenAge](https://ageapi.org) to implement multiple verification methods with a single integration.
:::
## What's AgeKey?
AgeKey is a privacy-preserving age verification system that allows users to prove their age without repeatedly sharing sensitive personal information. Think of it as a digital credential stored securely on a user's device that can verify age thresholds (such as "over 13" or "over 18") without revealing the person's date of birth or identity.
## How AgeKey works
AgeKey uses passkey technology (similar to Touch ID or Face ID) to store verified age information on a user's device. Once created, an AgeKey can be used across multiple applications and services, reducing friction in age verification flows.
## Key concepts
### AgeKey
A passkey stored on the user's device that contains verified age signals. It proves the user has passed certain age thresholds without exposing their exact age or date of birth.
### Use AgeKey flow
When a user needs to verify their age and already has an AgeKey saved, they can use this flow to quickly prove they meet age requirements. The user authenticates using their device's passkey system, which can include biometric authentication such as fingerprint scans, Face ID, or other supported methods. This is the faster, returning-user experience.

### Create AgeKey flow
After completing an age verification with a vendor (such as ID scanning or credit card verification), users can save the result as an AgeKey for future use. The user creates a passkey on their device, which is for future age verifications. This is typically offered as an option after successful verification.

## Integration overview
AgeKey uses OpenID Connect (OIDC) standards, so the patterns feel familiar if you've integrated OAuth 2.0 or social login before. Two main flows are available for integration:
### 1. Use AgeKey (returning users)
- User clicks "Verify with AgeKey"
- Browser redirects to AgeKey service
- User authenticates with their passkey
- Browser returns with a signed token containing age-threshold results
- Your server validates the token and grants access
### 2. Create AgeKey (new users)
- User completes age verification through your existing vendor
- Your server submits verification details to AgeKey via PAR and receives a short-lived `request_uri`
- Browser redirects to AgeKey service
- User creates their passkey on-device
- Browser returns to your app (success or decline)
## Key benefits
- **Privacy First**: No PII shared, only boolean results for age thresholds.
- **User Control**: Users decide when to create and use their AgeKey.
- **Cryptographic Security**: All results are signed and verifiable with public keys.
- **Cross-Platform**: Works across multiple applications and services.
## Security & privacy
::::tip Privacy-First Design
AgeKey is designed with privacy at its core:
- **No PII Shared**: Your app receives only boolean results (true/false for age thresholds), never raw dates of birth or ID documents
- **User Control**: Users decide when to create and use their AgeKey
- **Cryptographic Verification**: All results are signed and can be verified using public keys
- **CSRF & Replay Protection**: Built-in security measures using `state` and `nonce` parameters
::::
## Prerequisites
Before you begin integration, you'll need:
1. **Client Credentials**: Obtain `client_id` and `client_secret` from AgeKey
2. **Registered Redirect URI**: Register the URL where users return after AgeKey flows
3. **OIDC Client Library**: Choose an [OpenID Connect certified library](https://openid.net/developers/certified-openid-connect-implementations/) for your platform
## Key endpoints
**Use AgeKey Service:**
- Issuer: `https://api.agekey.org/v1/oidc/use`
- Discovery: `https://api.agekey.org/v1/oidc/use/.well-known/openid-configuration`
**Create AgeKey Service:**
- Issuer: `https://api.agekey.org/v1/oidc/create`
- Discovery: `https://api.agekey.org/v1/oidc/create/.well-known/openid-configuration`
- PAR Endpoint: [`https://api.agekey.org/v1/oidc/create/par`](/api/endpoints/create-agekey-par)
**Verification Keys:**
- JWKS: `https://api.agekey.org/.well-known/jwks.json`
## Choose your flow
::::note Need help?
Check out the integration guides or contact support.
::::
---
// File: quickstart
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Quick start: Verify with AgeKey
Implement AgeKey verification in four simple steps.
:::tip Fastest way to get started
The quickest way to see AgeKey in action is to run the complete [Next.js sample project](https://github.com/kidentify/agekey-sample). It's an open source implementation showing both Use AgeKey and Create AgeKey flows with working code you can copy and adapt.
:::
## 1) Set up your credentials
### Get your client credentials
Contact AgeKey to obtain your `client_id` and `client_secret` for AgeKey integration.
### Register redirect URIs
When contacting AgeKey for credentials, provide the redirect URIs you plan to use. These are the locations where your users are redirected to resume the age verification flow after using or saving an AgeKey.
This example uses `https://yourapp.com/agekey/callback` as a placeholder redirect URI. Replace this URL with your actual registered redirect URI.
## 2) Install dependencies
```bash
npm install openid-client
```
```bash
npm install oidc-client
```
See the [OpenID Connect certified implementations](https://openid.net/developers/certified-openid-connect-implementations/) list.
## 3) Build AgeKey redirect URL with claims
Your verification button redirects to AgeKey with your requested age thresholds in `claims`.
:::tip UI language
Optionally add a **`language`** query parameter (IETF BCP 47, for example `pt-BR`) on `/v1/oidc/use` or `/v1/oidc/create` to set the AgeKey UI language; when omitted, the UI follows the browser. Upgrade flows use `/v1/oidc/use` with the same optional parameter. See the [API reference](/api/endpoints/use-agekey#parameters).
:::
:::tip Using an iframe?
If you're embedding the Use AgeKey flow in an iframe, you must include the `publickey-credentials-get` permission in the `allow` attribute:
```html
```
:::
```javascript
// Browser-side using oidc-client's UserManager to handle state/nonce
import { UserManager } from 'oidc-client';
const settings = {
authority: 'https://api.agekey.org/v1/oidc/use',
client_id: 'your-client-id', // replace
redirect_uri: 'https://yourapp.com/agekey/callback',
response_type: 'id_token',
scope: 'openid',
// AgeKey requires the claims parameter with requested thresholds
extraQueryParams: {
claims: JSON.stringify({ age_thresholds: [13, 18] })
}
};
const userManager = new UserManager(settings);
async function verifyWithAgeKey() {
// Automatically generates and stores state/nonce
await userManager.signinRedirect();
}
// Example usage in your UI:
//
```
## 4) Handle the AgeKey callback
The OIDC library processes the response for you, validating state, nonce, and the JWT ID Token, then exposes the results so you can read `age_thresholds`.
```javascript
import { UserManager } from 'oidc-client';
const userManager = new UserManager({
authority: 'https://api.agekey.org/v1/oidc/use',
client_id: 'your-client-id',
redirect_uri: 'https://yourapp.com/agekey/callback',
response_type: 'id_token',
response_mode: 'fragment',
scope: 'openid'
});
// On your callback page/component
async function handleAgeKeyCallback() {
try {
// Parses URL, validates state/nonce, and returns the user with id_token claims
const user = await userManager.signinRedirectCallback();
const sessionId = user.profile?.sub; // Session ID; log this for auditing
const results = user.profile?.age_thresholds; // for example, { "13": true, "18": false }
// Important: persist the session ID for invalidation auditing
console.log('AgeKey session ID:', sessionId);
if (results?.['18']) {
// 18+ content allowed
} else if (results?.['13']) {
// 13+ content allowed
} else {
// Under 13
}
} catch (err) {
console.error('Verification failed:', err);
}
}
// Call this on load of your callback route/page
handleAgeKeyCallback();
```
## What's next?