This guide illustrates how to use client-signed JWT authentication with SDEP.
- Practice in a local environment (Local)
- Deploy against SDEP pre-production (PRE) and production (PRD)
- Overview
- Step 1: Configure environment (as admin)
- Step 2: Configure keypair (as admin)
- Step 3: Send keypair (as admin)
- Step 4: Receive connection info (as admin)
- Step 5: Create a client-signed JWT (as machine)
- Step 6: Authenticate (as machine)
- Step 7: Ping (as machine)
- Step 8: Invoke the SDEP API (as machine)
- Step 9: Authenticate (as admin, in Swagger)
- Step 10: Rotate keys (admin)
SDEP supports OAuth 2.0 with the Client Credentials Grant.
The Client Credentials Grant itself supports two client authentication methods:
- Client ID & secret: the client sends a shared secret (to SDEP)
- Client-signed JWT: the client signs a JWT with its private key (and send it to SDEP), after registering its public key once (at SDEP,
private_key_jwt, RFC 7523)
For SDEP, both authentication methods operate on the same /token endpoint.
- See Authentication and authorization for more info on both authentication methods.
- This document focuses on client-signed JWT.
Client-signed JWT authentication uses a private/public key pair to get authenticated.
The following actions are performed by the client, and are further explained in the sections below:
- As admin:
- Create a private/public key pair
- Keep the private key for yourself
- Submit the public key to team SDEP
- Receive connection info (such as the public key ID) from team SDEP
- As machine (program):
- Create a signed JWT from the private key and the connection info
- Authenticate at the SDEP
/tokenendpoint, using the signed JWT - Receive a
Bearertoken from the/tokenendpoint - Invoke the functional (authenticated) SDEP API endpoints, using the received
Bearertoken in the HTTPAuthorizationheader - Re-authenticate before the
Bearertoken expires (5 minutes), or on the first401
- Rotate the private/public key pair according to own security guidelines
The following actions are performed by team SDEP (e.g. SDEP-NL), and are further explained in the sections below:
- Receive a request for SDEP access from the client (incl. public key)
- Create a Keycloak machine client (with required roles), containing the public key (identified by public key ID =
kid) - Hand out the connection info (incl.
kid) back to the client
In your .env.extra, set client-secret authentication to false:
CLIENT_SECRET_AUTH_ENABLED=falseRestart the backend for the change to take effect:
make backend-restartResult:
- Only client-signed JWT authentication remains enabled
- In Swagger UI, authorization is performed exclusively using a Bearer token
- See step 9 for follow-up
N/A.
N/A.
In your local development environment, configuring a keypair is automated by any of these make commands:
make up # Idempotent
└── make keycloak-configure
└── keycloak-generate-machine-clientsThese commands use scripts/generate-keycloak-machine-clients.py, which configures a client-signed JWT test client for each of the supported SDEP roles:
┌───────────────────┬─────────────────────────────────┐
│ Client id │ Roles │
├───────────────────┼─────────────────────────────────┤
│ sdep-test-ca.jwt │ sdep_ca, sdep_read, sdep_write │
├───────────────────┼─────────────────────────────────┤
│ sdep-test-str.jwt │ sdep_str, sdep_read, sdep_write │
├───────────────────┼─────────────────────────────────┤
│ sdep-test-sta.jwt │ sdep_sta, sdep_read │
├───────────────────┼─────────────────────────────────┤
│ sdep-test-lsa.jwt │ sdep_lsa, sdep_read, sdep_write │
├───────────────────┼─────────────────────────────────┤
│ sdep-test-lma.jwt │ sdep_lma, sdep_read │
├───────────────────┼─────────────────────────────────┤
│ sdep-test-ama.jwt │ sdep_ama, sdep_read │
└───────────────────┴─────────────────────────────────┘
Generated configuration is as follows:
Private keys
Config:
./tmp/sdep-test-str.jwt.private.pem./tmp/sdep-test-sta.jwt.private.pem./tmp/sdep-test-ca.jwt.private.pem
These will be used to authenticate at the local SDEP /token endpoint.
Public keys
Config:
./tmp/sdep-test-ca.jwt.public.yaml./tmp/sdep-test-sta.jwt.public.yaml./tmp/sdep-test-str.jwt.public.yaml
These are used to extend the default keycloak/machine-clients.yaml:
Machine clients
Config:
tmp/machine-clients-extended.yaml
This is the extended keycloak/machine-clients.yaml, and is fed into Keycloak.
Smoke test
For each test client, verify that the public key stored in Keycloak matches the private key in your own possession.
make keycloak-match-client-public-keysOr show the public key for a single client instead:
make keycloak-show-client-public-key CLIENT_ID=sdep-test-str.jwt # or any other client in the table of step 2aAs admin, on the client system, generate a keypair:
openssl genpkey \
-algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out your.private.pem
chmod 600 your.private.pem
openssl pkey \
-in your.private.pem \
-pubout \
-out your.public.pemThis creates:
your.private.pem: a private key that will be used by your client to authenticate (by signing token requests at the/tokenendpoint)your.public.pem: a public key that you will send to SDEP, to onboard your client in keycloak
Smoke test (pair):
diff <(openssl pkey -in your.private.pem -pubout) your.public.pem \
&& echo "✅ pair matches" || echo "❌ pair does NOT match"
Always keep the private key exclusively in your possession.
Same as PRE.
N/A.
Send the complete content the your.public.pem you created in step 2b. to team SDEP, including the PEM markers:
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
Always keep the private key exclusively in your possession.
Same as PRE.
Info comprises the public key ID to use (kid), as well as other connection details.
Public key ID and connection details are predefined.
Create exports based on .env:
set -a && source .env && set +a
export SDEP_BASE_URL="$BACKEND_BASE_URL"
export SDEP_TOKEN_URL="${SDEP_BASE_URL%/}/api/auth/v1/token"
export CLIENT_SIGNED_JWT_AUDIENCE="${BACKEND_KC_BASE_URL%/}/realms/sdep/protocol/openid-connect/token"Then create the exports for one of the client-signed JWT test clients, as configured in step 2a.:
export CLIENT_ID=sdep-test-str.jwt # or any other client in the table of step 2a
export KEY_FILE="tmp/$CLIENT_ID.private.pem"
export KID="$CLIENT_ID"You will receive public key-id and connection details from team SDEP.
Based on this, equip your client environment.
For example as follows.
Private key
Create an export based on the private key file that you created in step 2b:
export KEY_FILE="your.private.pem"; echo KEY_FILE $KEY_FILEPublic key and connection
Create exports based on the values from team SDEP:
export SDEP_BASE_URL="as_received"; echo SDEP_BASE_URL $SDEP_BASE_URL
export SDEP_TOKEN_URL="as_received"; echo SDEP_TOKEN_URL $SDEP_TOKEN_URL
export CLIENT_ID="as_received"; echo CLIENT_ID $CLIENT_ID
export KID="as_received"; echo KID $KID
export CLIENT_SIGNED_JWT_AUDIENCE="as_received"; echo CLIENT_SIGNED_JWT_AUDIENCE $CLIENT_SIGNED_JWT_AUDIENCEExplanation:
| Value | Purpose | Will appear in JWT as |
|---|---|---|
SDEP_BASE_URL |
SDEP API base URL | |
SDEP_TOKEN_URL |
SDEP API token endpoint (to authenticate) | |
CLIENT_ID |
Client-signed JWT payload: issuer and subject | iss, sub |
KID |
Client-signed JWT header: public key identifier | kid |
CLIENT_SIGNED_JWT_AUDIENCE |
Client-signed JWT payload: audience [1] | aud |
[1] This identifies the intended recipient of the JWT (the authorization server, e.g. Keycloak)
Same as PRE.
To prepare for authentication.
For one-time usage (defined by the authorization server, repeat for each new authentication in step 6a) and only valid for 60 seconds (defined by the invoked create-client-signed-jwt.py).
Programmatically, by example:
export CLIENT_SIGNED_JWT="$(
uv run scripts/create-client-signed-jwt.py \
--token-url "$CLIENT_SIGNED_JWT_AUDIENCE" \
--client-id "$CLIENT_ID" \
--key-file "$KEY_FILE" \
--kid "$KID"
)"
echo $CLIENT_SIGNED_JWTThis example uses Python; you can also implement this in your own stack.
Details:
- SDEP maps
client_signed_jwtto the authorization server's standard OAuthprivate_key_jwtrequest fields - The script sets the required claims (
iss,sub,aud,iat,exp,jti) and theRS256/kidheader
Same as Local.
Same as Local.
Use the client-signed JWT within 60 seconds, and only once, to invoke the /token endpoint:
# Get token response
export TOKEN_RESPONSE="$(
curl -sS -X POST "$SDEP_TOKEN_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_signed_jwt=$CLIENT_SIGNED_JWT"
)"
printf 'TOKEN_RESPONSE:\n\n'
echo "$TOKEN_RESPONSE" | jq .
# Extract access token (Bearer) from token response
export ACCESS_TOKEN="$(echo "$TOKEN_RESPONSE" | jq -er '.access_token')" \
|| { echo "❌ No access token:"; echo "$TOKEN_RESPONSE" | jq .; }Remarks:
- The access token is used in the
Authorizationheader when calling the API in the next step. - The access token expires after 5 minutes.
- Automate this step when your client needs long-running access.
Same as Local.
Same as Local.
Verify the token with the role-agnostic ping endpoint:
curl -sS "$SDEP_BASE_URL/api/ping" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .A valid token returns {"status": "OK"}.
Reuse the same ACCESS_TOKEN for as many calls as needed, until it expires after 5 minutes.
Same as Local.
Same as Local.
See examples below, assume client environment is already set.
Same as Local.
Same as Local.
Only when having the Competent Authority role (CA).
# Count the own areas
curl -sS "$SDEP_BASE_URL/api/ca/v1/areas/count" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .
# Get the own areas (first 2)
curl -sS "$SDEP_BASE_URL/api/ca/v1/areas?limit=2" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .
# Get the activities in the own areas (first 2)
curl -sS "$SDEP_BASE_URL/api/ca/v1/activities?limit=2" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .Example response:
{
"areas": [
{
"areaId": "58ff0814-3aa1-5019-9afb-3cd9f398602c",
"areaName": "Amsterdam",
"regulation": "all",
"filename": "Amsterdam.zip",
"competentAuthorityId": "c4ac8ccf-a281-5789-bad7-28dfac20ca7f",
"competentAuthorityName": "Amsterdam (inclusief Weesp)",
"createdAt": "2025-01-01T00:00:00Z"
}
...
]
}Remarks:
- Results are scoped to the authenticated competent authority, based on the
client_idin the access token - See
docs/API_TECH.mdfor the full endpoint list, including area upload and delete
CA: Activity filters
The activity endpoints accept four optional filters.
# Count the own activities created in June 2025
curl -sS -G "$SDEP_BASE_URL/api/ca/v1/activities/count" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
--data-urlencode "createdAtFrom=2025-06-01T00:00:00Z" \
--data-urlencode "createdAtTo=2025-06-30T23:59:59Z" \
| jq .
# Get the own activities for one area and one platform (first 2)
curl -sS -G "$SDEP_BASE_URL/api/ca/v1/activities" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
--data-urlencode "areaId=58ff0814-3aa1-5019-9afb-3cd9f398602c" \
--data-urlencode "platformId=8e70f1e2-4c61-477b-89b8-0dbf25ab8b21" \
--data-urlencode "limit=2" \
| jq .Example response (count):
{
"count": 42
}Remarks:
- Filters combine with AND, omitting a filter means no constraint on that dimension
createdAtFromandcreatedAtToare inclusive and must be UTC (Zor+00:00); a datetime with no offset or another offset returns HTTP 400platformIdandareaIdare exact-match functional IDs, an invalid format returns HTTP 400- Use
curl -G --data-urlencodeso the:in the timestamps is encoded for you - For OR semantics, call the endpoint per value and combine the results client-side
Only when having the platform role (STR).
# Count the areas
curl -sS "$SDEP_BASE_URL/api/str/v1/areas/count" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .
# Get the areas (first 2)
curl -sS "$SDEP_BASE_URL/api/str/v1/areas?limit=2" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .Example response:
{
"areas": [
{
"areaId": "86de11c8-1744-5241-9c82-a19444d7a6d8",
"areaName": "Zwolle",
"regulation": "all",
"filename": "Zwolle.zip",
"competentAuthorityId": "10f2b986-802c-537f-82d2-8069a25c6c11",
"competentAuthorityName": "Zwolle",
"createdAt": "2025-01-01T00:00:00Z"
}
...
]
}Only when having "statistics authority" role (STA).
# Count all activities
curl -sS "$SDEP_BASE_URL/api/sta/v1/activities/count" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .
# Get all activities (first 2)
curl -sS "$SDEP_BASE_URL/api/sta/v1/activities?limit=2" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .Example response:
{
"activities": [
{
"activityId": "550e8400-e29b-41d4-a716-446655440000",
"activityName": "Amsterdam Summer Rental",
"status": "finished",
"areaId": "58ff0814-3aa1-5019-9afb-3cd9f398602c",
"areaName": "Amsterdam",
"competentAuthorityId": "c4ac8ccf-a281-5789-bad7-28dfac20ca7f",
"competentAuthorityName": "Gemeente Amsterdam",
"url": "http://example.com/amsterdam-myhouse-1",
"address": {
"thoroughfare": "Prinsengracht",
"locatorDesignatorNumber": 263,
"postCode": "1016GV",
"postName": "Amsterdam",
"fullAddress": "Prinsengracht 263, 1016GV Amsterdam"
},
"registrationNumber": "REG0001",
"numberOfGuests": 4,
"countryOfGuests": ["NLD", "DEU", "BEL", "N/A"],
"temporal": {
"startDatetime": "2025-06-01T14:00:00Z",
"endDatetime": "2025-06-07T11:00:00Z"
},
"platformId": "8e70f1e2-4c61-477b-89b8-0dbf25ab8b21",
"platformName": "Test STR 01 (interactive usage, persistent)",
"createdAt": "2025-06-01T12:00:00Z"
}
...
]
}Remarks:
- Results are not scoped: they cover all competent authorities and all platforms
limitdefaults to 1000, which is also the maximum - page withoffsetand/activities/count- Optional filters (AND semantics):
createdAtFrom,createdAtTo,platformId,areaId,competentAuthorityId
Only when having "listing screening authority" role (LSA).
The screening authority reads the listings waiting to be screened, and posts the result back. Both steps are listings (random checks), see Listing.
# Count the listings waiting to be screened
curl -sS "$SDEP_BASE_URL/api/lsa/v2/listings/count" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .
# Get the listings waiting to be screened (first 2)
curl -sS "$SDEP_BASE_URL/api/lsa/v2/listings?limit=2" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .Example response:
{
"listings": [
{
"listingId": "amsterdam-listing-0001",
"listingName": "Amsterdam Canal Apartment",
"status": "pending",
"areaId": "58ff0814-3aa1-5019-9afb-3cd9f398602c",
"areaName": "Amsterdam",
"competentAuthorityId": "c4ac8ccf-a281-5789-bad7-28dfac20ca7f",
"competentAuthorityName": "Gemeente Amsterdam",
"url": "http://example.com/amsterdam-myhouse-1",
"address": {
"thoroughfare": "Prinsengracht",
"locatorDesignatorNumber": 263,
"postCode": "1016GV",
"postName": "Amsterdam",
"fullAddress": "Prinsengracht 263, 1016GV Amsterdam"
},
"declaredAsShortTermRental": true,
"registrationNumber": "REG0001",
"flags": [],
"submittedAt": "2026-09-01T09:00:00Z",
"platformId": "8e70f1e2-4c61-477b-89b8-0dbf25ab8b21",
"platformName": "Test STR 01 (interactive usage, persistent)",
"createdAt": "2026-09-01T09:00:00Z"
}
...
]
}Post the screening result. createdAt is the version you screened, copied from the
response above:
curl -sS -X POST "$SDEP_BASE_URL/api/lsa/v2/listing-screenings/bulk" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"screenings": [
{
"platformId": "8e70f1e2-4c61-477b-89b8-0dbf25ab8b21",
"listingId": "amsterdam-listing-0001",
"createdAt": "2026-09-01T09:00:00Z",
"flags": ["UNK"]
}
]
}' \
| jq .Remarks:
- The read is fixed to
pendinglistings across every platform: the scope is not a filter and cannot be widened - An empty
flagsarray is a valid result: it moves the listing toclear createdAtis the concurrency token. If the platform corrected the listing in the meantime, the item is refused withconflict_erroroncreatedAt, and the corrected listing shows up in your next read- Optional filters (AND semantics):
createdAtFrom,createdAtTo,areaId,platformId
Only when having "listing monitoring authority" (LMA) or "activity monitoring authority" (AMA) role.
Both are read-only and unscoped, like STA. LMA reads listings in every lifecycle status, AMA reads activities:
# LMA: all listings, any status (first 2)
curl -sS "$SDEP_BASE_URL/api/lma/v2/listings?limit=2" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .
# LMA: only the flagged ones
curl -sS -G "$SDEP_BASE_URL/api/lma/v2/listings" \
--data-urlencode "status=flagged" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .
# AMA: all activities (first 2)
curl -sS "$SDEP_BASE_URL/api/ama/v1/activities?limit=2" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
| jq .The response shapes are the same as 8f and 8g.
Remarks:
- LMA and STA v2 have the
statusandflagsfilters (comma-separated flag codes), CA v2 hasflagsonly; AMA has neither - AMA serves the same read as STA, behind its own role
Make sure your environment is prepared for client-signed JWT (see step 1).
In Swagger UI, select Authorize and paste the Bearer token you programmatically obtained in step 6.
N/A - in PRE, Swagger authorization is always performed using client ID & secret, not by a Bearer token that is acquired by a client-signed JWT. Thus in PRE, in Swagger, keep using a client ID & secret instead.
In Swagger UI, select Authorize and paste the Bearer token you programmatically obtained in step 6.
Delete the generated private key and rerun local setup:
rm tmp/*.private.pem
make keycloak-generate-machine-clients
make keycloak-configureRotate a key by coordinating the public-key update with SDEP:
- Generate a new private/public key pair.
- Send the new public key to SDEP.
- Wait for SDEP to assign and confirm the new
kid. - Start signing new client-signed JWTs with the new private key and
kid. - Keep the old private key only until SDEP confirms it is no longer accepted.
Same as PRE.