Get started
API scopes
Integration guides
Features
Troubleshooting
No framework (curl)
Use this guide to learn the protocol, debug other integrations, or build for a language or runtime not covered elsewhere. For production apps, prefer one of the framework guides — they handle the dance below for you.
This guide walks through a full Authorization Code + PKCE flow against Kenni using nothing but curl and a browser.
You'll need a Web or SPA application registered in the developer portal with one redirect URI on the list (we'll use http://localhost:8080/callback below).
Replace <team-domain> with your team's domain throughout. Your client_id is shown on the application's Overview tab — it looks like @my-app.is/web.
Prerequisites
The snippets below assume bash, curl, jq, openssl, and xxd. On macOS, jq is not installed by default — brew install jq. openssl and xxd ship with the OS. The JWT-verification recipe at the end uses openssl exclusively (no jq substitutes for the cryptography).
Discovery
Every Kenni team exposes an OIDC discovery document. It's the only URL your code needs to hard-code; everything else is derived from it.
curl -s https://idp.kenni.is/<team-domain>/.well-known/openid-configuration | jq . > discovery.json
The response lists authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, end_session_endpoint, supported scopes, and supported response/grant types. Cache it for the rest of the session — re-fetch only when the JWKS rotates.
AUTH_ENDPOINT=$(jq -r .authorization_endpoint discovery.json)
TOKEN_ENDPOINT=$(jq -r .token_endpoint discovery.json)
USERINFO_ENDPOINT=$(jq -r .userinfo_endpoint discovery.json)
JWKS_URI=$(jq -r .jwks_uri discovery.json)
END_SESSION_ENDPOINT=$(jq -r .end_session_endpoint discovery.json)
Generate PKCE values
PKCE binds the authorization request to the token exchange so an intercepted code is useless on its own. Generate a verifier (random) and a challenge (its SHA-256, base64url-encoded):
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=\n' | tr '/+' '_-')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr -d '=' | tr '/+' '_-')
The verifier is a 43-character base64url string drawn from the full RFC 7636 alphabet ([A-Z][a-z][0-9]-._~). Don't try to "simplify" the alphabet by stripping -_ — every character of entropy counts.
state and nonce are also worth setting — both are echoed back on the callback and used to detect tampering and replay:
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)
Build the authorization URL
Open this URL in a browser. Don't curl it — the response is a redirect into the Kenni login UI, which the user has to interact with.
URL="$AUTH_ENDPOINT?client_id=@<team-domain>/web"
URL+="&response_type=code"
URL+="&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback"
URL+="&scope=openid+profile+national_id+offline_access"
URL+="&state=$STATE"
URL+="&nonce=$NONCE"
URL+="&code_challenge=$CODE_CHALLENGE"
URL+="&code_challenge_method=S256"
# macOS: open "$URL" Linux: xdg-open "$URL" Windows: start "$URL"
echo "$URL"
offline_access is what gets you a refresh token — drop it if you don't need refresh.
Receive the redirect
The browser redirects to http://localhost:8080/callback?code=...&state=... (or ?error=...&error_description=... if the user declined). You need something listening on that port to display or capture the URL. The simplest portable approach is a tiny static server:
mkdir -p callback
cat > callback/index.html <<'HTML'
<!doctype html>
<meta charset="utf-8">
<title>Kenni callback</title>
<pre id="out"></pre>
<script>document.getElementById('out').textContent = location.search</script>
HTML
python3 -m http.server 8080
Open the auth URL, sign in, and copy the query string from the page that loads.
If nothing is listening on 8080 the browser shows "connection refused" — the URL still appears in the address bar, so copying from there works as a fallback.
Pull the values out of the redirect URL:
URL='http://localhost:8080/callback?code=...&state=...' # paste the URL
QUERY="${URL#*\?}"
get_param() {
printf '%s' "$QUERY" | tr '&' '\n' | awk -F= -v k="$1" '$1==k{print substr($0, length(k)+2); exit}'
}
urldecode() {
local s="${1//+/ }"
printf '%b' "${s//%/\\x}"
}
CODE=$(urldecode "$(get_param code)")
RETURNED_STATE=$(urldecode "$(get_param state)")
ERR=$(urldecode "$(get_param error)")
ERR_DESC=$(urldecode "$(get_param error_description)")
[[ -z "$ERR" ]] || { echo "auth failed: $ERR — $ERR_DESC" >&2; exit 1; }
[[ "$RETURNED_STATE" == "$STATE" ]] || { echo "state mismatch" >&2; exit 1; }
The state check is the CSRF defence; the error check covers the consent-decline path.
Exchange the code for tokens
Confidential (Web) clients authenticate the token request with their client_secret. Public clients (SPA, Native) omit it — PKCE is the proof.
RESPONSE=$(curl -s -X POST "$TOKEN_ENDPOINT" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=$CODE" \
--data-urlencode "redirect_uri=http://localhost:8080/callback" \
--data-urlencode "client_id=@<team-domain>/web" \
--data-urlencode "client_secret=<client-secret>" \
--data-urlencode "code_verifier=$CODE_VERIFIER")
ACCESS_TOKEN=$(printf '%s' "$RESPONSE" | jq -r .access_token)
ID_TOKEN=$(printf '%s' "$RESPONSE" | jq -r .id_token)
REFRESH_TOKEN=$(printf '%s' "$RESPONSE" | jq -r .refresh_token)
--data-urlencode correctly escapes client IDs that contain @ or /. jq -r strips the surrounding quotes; printf '%s' strips jq's trailing newline so the values can be reused without contaminating the next request.
Do not trust the id_token payload before verifying its signature. A naive cut -d. -f2 | base64 -d | jq . decode of an id_token tells you what someone claimed — you don't know it was Kenni. See Verifying a JWT in shell below — apply the same recipe to $ID_TOKEN, then check that nonce in the payload equals the $NONCE you sent.
Call the user-info endpoint
The access token works against /oidc/me. The response contains the same identity claims released by the scopes the user consented to.
curl -s "$USERINFO_ENDPOINT" -H "Authorization: Bearer $ACCESS_TOKEN" | jq .
If you also requested a custom API scope, the access token is itself a JWT carrying many of the same claims — reading it directly skips a network round-trip when you only need to confirm identity. Verify the signature first (next section). If you didn't request a custom API scope, the access token is an opaque reference token — see API scopes for the difference.
Verifying a JWT in shell
The whole point of a "no framework" guide is to make the protocol concrete, and Kenni's tokens are signed JWTs. The recipe below verifies an RS256 JWT (id_token or access token) using only openssl and jq.
verify_jwt_rs256() {
local TOKEN="$1" JWKS="$2"
local HEADER_B64="${TOKEN%%.*}"
local REST="${TOKEN#*.}"
local PAYLOAD_B64="${REST%%.*}"
local SIG_B64="${REST#*.}"
local SIGNED_INPUT="$HEADER_B64.$PAYLOAD_B64"
# base64url -> base64 with padding, then decode
b64url_decode() {
local s="$1" pad
s="${s//-/+}"; s="${s//_/\/}"
pad=$(( (4 - ${#s} % 4) % 4 ))
printf '%s%*s' "$s" "$pad" '' | tr ' ' '=' | openssl base64 -d -A
}
# 1. Read kid + alg from the JOSE header
local HEADER ALG KID
HEADER=$(b64url_decode "$HEADER_B64")
ALG=$(printf '%s' "$HEADER" | jq -r .alg)
KID=$(printf '%s' "$HEADER" | jq -r .kid)
[[ "$ALG" == "RS256" ]] || { echo "alg=$ALG, expected RS256" >&2; return 1; }
# 2. Find the matching JWK by kid, pull its n and e
local JWK N_B64URL E_B64URL
JWK=$(printf '%s' "$JWKS" | jq -c --arg kid "$KID" '.keys[] | select(.kid==$kid)')
[[ -n "$JWK" ]] || { echo "no JWK for kid=$KID" >&2; return 1; }
N_B64URL=$(printf '%s' "$JWK" | jq -r .n)
E_B64URL=$(printf '%s' "$JWK" | jq -r .e)
# 3. Convert n,e -> PEM. ASN.1 INTEGER is signed; if the RSA modulus's
# high bit is set (which it is for any real RSA key), prepend 00.
local TMP; TMP=$(mktemp -d)
b64url_decode "$N_B64URL" > "$TMP/n.bin"
b64url_decode "$E_B64URL" > "$TMP/e.bin"
prepend_zero_if_signed() {
local f="$1" first
first=$(xxd -p -l 1 "$f")
if [[ "$((16#$first))" -ge 128 ]]; then
{ printf '\x00'; cat "$f"; } > "$f.tmp" && mv "$f.tmp" "$f"
fi
}
prepend_zero_if_signed "$TMP/n.bin"
prepend_zero_if_signed "$TMP/e.bin"
# Build an ASN.1 RSAPublicKey SEQUENCE { n INTEGER, e INTEGER } via openssl asn1parse -genconf
local N_HEX E_HEX
N_HEX=$(xxd -p -c 9999 "$TMP/n.bin")
E_HEX=$(xxd -p -c 9999 "$TMP/e.bin")
cat > "$TMP/asn1.cnf" <<CONF
asn1 = SEQUENCE:rsa_pub
[rsa_pub]
n = INTEGER:0x$N_HEX
e = INTEGER:0x$E_HEX
CONF
openssl asn1parse -genconf "$TMP/asn1.cnf" -out "$TMP/pub.der" -noout
openssl rsa -RSAPublicKey_in -inform DER -in "$TMP/pub.der" -pubout -out "$TMP/pub.pem" 2>/dev/null
# 4. Verify signature
b64url_decode "$SIG_B64" > "$TMP/sig.bin"
if ! printf '%s' "$SIGNED_INPUT" \
| openssl dgst -sha256 -verify "$TMP/pub.pem" -signature "$TMP/sig.bin" >/dev/null 2>&1; then
echo "signature INVALID" >&2
rm -rf "$TMP"
return 1
fi
# 5. Emit the (now-trusted) payload for further claim checks
b64url_decode "$PAYLOAD_B64"
rm -rf "$TMP"
}
Use it:
JWKS=$(curl -s "$JWKS_URI")
PAYLOAD=$(verify_jwt_rs256 "$ID_TOKEN" "$JWKS") || exit 1
# Claim checks
NOW=$(date +%s)
EXP=$(printf '%s' "$PAYLOAD" | jq -r .exp)
ISS=$(printf '%s' "$PAYLOAD" | jq -r .iss)
AUD=$(printf '%s' "$PAYLOAD" | jq -r .aud)
TOK_NONCE=$(printf '%s' "$PAYLOAD" | jq -r .nonce)
[[ "$NOW" -lt "$EXP" ]] || { echo "token expired" >&2; exit 1; }
[[ "$ISS" == "https://idp.kenni.is/<team-domain>" ]] || { echo "wrong issuer: $ISS" >&2; exit 1; }
[[ "$AUD" == "@<team-domain>/web" ]] || { echo "wrong audience: $AUD" >&2; exit 1; }
[[ "$TOK_NONCE" == "$NONCE" ]] || { echo "nonce mismatch" >&2; exit 1; } # id_token only
printf '%s' "$PAYLOAD" | jq .
The same function verifies API access tokens (omit the nonce check, validate the API scope claim instead).
The leading \x00 byte on the modulus is an ASN.1 quirk — INTEGER is signed two's-complement, so any value with the high bit set must be sign-extended to stay positive. Forget this and openssl rsa rejects the DER as "non-positive integer."
Refresh
When the access token expires, swap the refresh token for a fresh pair:
curl -s -X POST "$TOKEN_ENDPOINT" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
--data-urlencode "client_id=@<team-domain>/web" \
--data-urlencode "client_secret=<client-secret>" | jq .
Client credentials (M2M)
For server-to-server calls there's no user, no PKCE, no browser — just a confidential client exchanging its credentials directly for an access token. Use the Machine application type in the developer portal.
# RFC 6749 §2.3.1 says client_id and client_secret in a Basic header must each be
# form-urlencoded *before* the Base64. Kenni client IDs contain '@' and '/', so the
# naive `client_id:secret` Basic header technically violates the spec.
ID_ENC=$(jq -rn --arg v "$CLIENT_ID" '$v|@uri')
SECRET_ENC=$(jq -rn --arg v "$CLIENT_SECRET" '$v|@uri')
BASIC=$(printf '%s:%s' "$ID_ENC" "$SECRET_ENC" | openssl base64 -A)
curl -s -X POST "$TOKEN_ENDPOINT" \
-H "Authorization: Basic $BASIC" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "scope=$M2M_SCOPE" | jq .
The response contains an access_token (a JWT, with the M2M scope as scope) and expires_in. No refresh_token and no id_token — there's no user to identify. Verify the JWT exactly the same way as the user-flow access token.
Sign out
Clearing your local session is not enough. The user is still signed in to Kenni — the next time they hit your "Sign in" button, Kenni recognizes the existing session and logs them straight back in with no visible interaction. Users perceive this as "logout doesn't work."
The fix is RP-initiated logout: redirect the browser to Kenni's end_session_endpoint so Kenni clears its own session, then comes back to your app:
LOGOUT_URL="$END_SESSION_ENDPOINT?client_id=@<team-domain>/web"
LOGOUT_URL+="&id_token_hint=$ID_TOKEN"
LOGOUT_URL+="&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Floggedout"
# macOS: open "$LOGOUT_URL"
echo "$LOGOUT_URL"
id_token_hint is the ID token you received at sign-in. post_logout_redirect_uri must be on the application's Post logout redirect URIs list in the developer portal — register a separate path from the sign-in redirect URI so your app can tell sign-in completion apart from sign-out completion.
When to clear the local session: either right before redirecting to end_session_endpoint, or in your post_logout_redirect_uri handler. Both work; pick one and be consistent. Most apps clear it before the redirect so users see a logged-out UI even if the round-trip fails.
That's the full loop. Every framework guide that follows is essentially a way to delegate the work above to a library.
Next steps
Drop Kenni into a Next.js app via better-auth's generic OAuth plugin.
Public-client flow with react-oidc-context.
Native mobile flow with expo-auth-session.
Native iOS/Android via AppAuth.
Server-side Node with openid-client.
Configure Spring Security as an OIDC client.
Wire Kenni up via AddOpenIdConnect.
golang.org/x/oauth2 + coreos/go-oidc.
Authlib for Flask, FastAPI, or standalone.
Ruby, PHP, Rust, and any OIDC-conformant library.