auth.isdocs
Integration guides

No framework (curl)

This walks a full Authorization Code + PKCE flow against your auth.is issuer with nothing but curl, openssl, and a browser. It mirrors, step for step, the end-to-end flow auth.is tests itself with.

Throughout, replace {slug} with your issuer slug, so {slug}.auth.is is your issuer host. You need a client registered under that issuer (see Clients) with a redirect URI on its list — we use https://app.example.com/callback below. The snippets assume bash, curl, and openssl.

1. Discovery

Every issuer publishes an OIDC discovery document. It is the only URL you hard-code; every endpoint is derived from it.

BASE="https://{slug}.auth.is"
curl -s "$BASE/.well-known/openid-configuration" > discovery.json

# Pull the endpoints out (python3 avoids a jq dependency).
jqval() { python3 -c "import sys,json;print(json.load(open('discovery.json'))['$1'])"; }
AUTH_ENDPOINT=$(jqval authorization_endpoint)   # {BASE}/oidc/auth
TOKEN_ENDPOINT=$(jqval token_endpoint)          # {BASE}/oidc/token
USERINFO_ENDPOINT=$(jqval userinfo_endpoint)    # {BASE}/oidc/me
JWKS_URI=$(jqval jwks_uri)                       # {BASE}/oidc/jwks
END_SESSION_ENDPOINT=$(jqval end_session_endpoint)     # {BASE}/oidc/session/end
REVOCATION_ENDPOINT=$(jqval revocation_endpoint)       # {BASE}/oidc/token/revocation

2. Generate PKCE, state, and nonce

PKCE binds the authorization request to the token exchange, so an intercepted code is useless on its own. auth.is requires PKCE for every client — there is no non-PKCE path.

b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }

CODE_VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -binary -sha256 | b64url)
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)

state and nonce are echoed back and let you detect CSRF and replay. Keep all four values for the steps below.

3. Build the authorization URL

Open this in a browser — do not curl it. The response is a redirect into the login UI, which the user interacts with.

CLIENT_ID="acme-web"
REDIRECT_URI="https://app.example.com/callback"

URL="$AUTH_ENDPOINT?client_id=$CLIENT_ID"
URL+="&response_type=code"
URL+="&redirect_uri=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$REDIRECT_URI")"
URL+="&scope=openid+profile+email+offline_access"
URL+="&state=$STATE"
URL+="&nonce=$NONCE"
URL+="&code_challenge=$CODE_CHALLENGE"
URL+="&code_challenge_method=S256"
URL+="&prompt=consent"

echo "$URL"   # macOS: open "$URL"   Linux: xdg-open "$URL"

The user signs in (username/password, a passkey, or a "Continue with …" button if you configured federation), and the browser is redirected to your redirect_uri with ?code=...&state=... — or ?error=...&error_description=... if they declined.

4. Receive the redirect and check state

You need something listening on the redirect URI to capture the query string. In real life your app's callback route does this; by hand, copy the URL out of the browser and parse it:

REDIRECT_URL='https://app.example.com/callback?code=...&state=...'   # paste it
QUERY="${REDIRECT_URL#*\?}"
getp() { printf '%s' "$QUERY" | tr '&' '\n' | awk -F= -v k="$1" '$1==k{print substr($0,length(k)+2)}'; }

CODE=$(getp code)
RETURNED_STATE=$(getp state)
[ "$RETURNED_STATE" = "$STATE" ] || { echo "state mismatch — abort"; exit 1; }

The state check is your CSRF defense.

5. Exchange the code for tokens

How you authenticate the token request depends on the client type (confidential vs. public).

Confidential client — authenticate with the client secret (HTTP Basic here, client_secret_basic), and include the PKCE verifier:

CLIENT_SECRET="the-secret-you-stored-once"

RESPONSE=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=$REDIRECT_URI" \
  --data-urlencode "code_verifier=$CODE_VERIFIER" \
  "$TOKEN_ENDPOINT")

Public client — no secret; PKCE is the proof. Send client_id in the body instead:

RESPONSE=$(curl -s \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=$REDIRECT_URI" \
  --data-urlencode "code_verifier=$CODE_VERIFIER" \
  --data-urlencode "client_id=$CLIENT_ID" \
  "$TOKEN_ENDPOINT")

Either way, pull out the tokens:

tokval() { printf '%s' "$RESPONSE" | python3 -c "import sys,json;print(json.load(sys.stdin).get('$1',''))"; }
ACCESS_TOKEN=$(tokval access_token)
ID_TOKEN=$(tokval id_token)
REFRESH_TOKEN=$(tokval refresh_token)   # present only if offline_access + prompt=consent + refresh_token grant

The id_token carries the user's identity claims directly — auth.is includes email, name, and the rest in it rather than stripping to sub, so most libraries read the user straight off the ID token. You can inspect its payload (base64url-decode the middle segment):

printf '%s' "$ID_TOKEN" | cut -d. -f2 | tr '_-' '/+' | { cat; printf '=='; } | openssl base64 -d -A

6. Call userinfo

The access token works at the userinfo endpoint and returns the identity claims the granted scopes allow:

curl -s "$USERINFO_ENDPOINT" -H "Authorization: Bearer $ACCESS_TOKEN"

7. Refresh

When the access token expires, swap the refresh token for a fresh set. Confidential clients authenticate the same way as at step 5 (-u "$CLIENT_ID:$CLIENT_SECRET"); public clients send --data-urlencode "client_id=$CLIENT_ID".

curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$REFRESH_TOKEN" \
  "$TOKEN_ENDPOINT"

Default token lifetimes are 1 hour for the ID/access token and 14 days for the refresh token; an owner can tune them per client with update_client_token_ttls (id_token 60–14,400 s, refresh_token 60–4,838,400 s).

8. Revoke a token

To proactively kill a refresh (or access) token — for example on logout of a specific device — post it to the revocation endpoint (RFC 7009), authenticated like the token endpoint:

curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
  --data-urlencode "token=$REFRESH_TOKEN" \
  --data-urlencode "token_type_hint=refresh_token" \
  "$REVOCATION_ENDPOINT"

9. Sign out (RP-initiated logout)

Clearing your local session is not enough — the user still has a session at the issuer, so your next "Sign in" logs them straight back in with no visible interaction. Redirect the browser to the end-session endpoint so the issuer clears its own session:

LOGOUT_URL="$END_SESSION_ENDPOINT?client_id=$CLIENT_ID"
LOGOUT_URL+="&id_token_hint=$ID_TOKEN"
LOGOUT_URL+="&post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Floggedout"
echo "$LOGOUT_URL"   # open in the browser

The post_logout_redirect_uri must be on the client's post-logout redirect URI list — set it with update_client (post_logout_redirect_uris). Register a different path from your sign-in callback so your app can tell sign-out completion apart from sign-in.

That is the full loop. Every framework guide is just a way to delegate this to a library.

Next steps