Sign in with Apple, full setup
The full setup for the trickiest OIDC provider
Sign in with Apple is OIDC-shaped but has quirks: private email relay, JWT-signed client_secret, name only on first login, etc. This is a complete setup walkthrough.
Prerequisites
- Apple Developer account (USD 99/year).
- A registered domain.
- Olympus deployed.
Apple Developer console
Step 1: App ID
- Identifiers → Add → App IDs.
- Description: "Your App".
- Bundle ID:
com.your-domain.app(reverse-DNS style). - Capabilities → enable "Sign in with Apple."
- Save.
Step 2: Services ID
This is what Apple calls a "web app" client.
- Identifiers → Add → Services IDs.
- Description: "Your App Web".
- Identifier:
com.your-domain.app.web(distinct from App ID). - Enable Sign in with Apple → configure:
- Primary App ID: select your App ID.
- Domains:
your-domain.com - Return URLs:
https://ciam.your-domain.com/self-service/methods/oidc/callback/apple
- Save.
Step 3: Key
- Keys → Add → Sign in with Apple.
- Configure: pick your App ID.
- Download the
.p8file. SAVE IT, you can't download again. - Note Key ID:
XXXXXXXXXX. - Note Team ID (top-right of console).
Generating client_secret
Apple doesn't accept a static client_secret. You generate a signed JWT and send that as client_secret. JWT lifetime: max 6 months.
import { sign } from "jsonwebtoken";
import fs from "fs";
const privateKey = fs.readFileSync("AuthKey_XXXXXX.p8");
const clientSecret = sign({}, privateKey, {
algorithm: "ES256",
keyid: "XXXXXXXXXX", // Key ID
issuer: "YOUR_TEAM_ID", // Apple Team ID
audience: "https://appleid.apple.com",
subject: "com.your-domain.app.web", // Services ID
expiresIn: "180d",
});
console.log(clientSecret);Save the output. This is your client_secret for the next 180 days.
Kratos OIDC config
selfservice:
methods:
oidc:
enabled: true
config:
providers:
- id: apple
provider: apple
client_id: com.your-domain.app.web # Services ID
client_secret: <THE_JWT_FROM_ABOVE>
scope:
- email
- name
apple_team_id: YOUR_TEAM_ID
apple_private_key_id: XXXXXXXXXX
apple_private_key: |
-----BEGIN PRIVATE KEY-----
<contents of .p8 file>
-----END PRIVATE KEY-----
mapper_url: file:///etc/config/oidc-apple.jsonnetSome Kratos versions support apple_private_key directly, newer versions auto-mint the JWT from the private key.
Jsonnet mapper
local claims = std.extVar('claims');
{
identity: {
traits: {
email: claims.email,
// Apple sends name only on first authorization, store if present:
first_name: if std.objectHas(claims, 'name') then claims.name.firstName else '',
last_name: if std.objectHas(claims, 'name') then claims.name.lastName else '',
},
},
}Apple's quirks
Name only on first authorization
Subsequent logins, the name claim is absent. The user can revoke and re-authorize to send it again.
Best practice: store the name if you get it, never overwrite with empty values:
first_name: if std.objectHas(claims, 'name')
then claims.name.firstName
else null, // Don't overwrite stored valuePrivate email relay
Many users choose "Hide my email." Apple gives you a randomized @privaterelay.appleid.com address that forwards to their real email.
This works for transactional email, Apple forwards. But:
- It's stable per (user, services ID), same user always gets the same relay.
- If user revokes access, the relay stops forwarding. You can't reach them.
- If you serialize the email to a key, treat relay emails as opaque IDs, not real addresses.
email_verified claim
Apple sets email_verified: true for real emails and unspecified for relays. Don't rely on it; treat all Apple emails as verified.
Account name display
The user's "name" returned (when returned) is their Apple-account display name, which might not match their legal name. UX-wise, label it as "Display name" not "Legal name."
Mobile (iOS) setup
For iOS apps, the flow is different, uses native Sign in with Apple, returns identity token directly. Send the identity token to your backend, which verifies and creates the Olympus session.
Hera + Kratos handle web Sign in with Apple. For mobile, see SDK guide.
Testing
- Visit Hera /login.
- Click "Sign in with Apple."
- You're redirected to Apple → enter Apple ID + password.
- (First time) consent screen, choose email visibility.
- Redirect back to Kratos callback.
- Identity created with email + (first time) name.
If you get "invalid_client":
- Verify
client_idmatches the Services ID, not App ID. - Verify the JWT is correctly signed (test with
jwt.io). - JWT expired? Re-mint.
If you get redirect URI mismatch:
- Apple's return URL must EXACTLY match what's registered.
- Trailing slash, scheme, etc.
Rotating client_secret JWT
Every 180 days:
- Mint new JWT.
- Update Kratos config.
- Restart Kratos.
Or have it minted dynamically (Kratos versions that support it).
Set a calendar reminder. If the JWT expires, all Sign in with Apple logins break until rotated.