OAuth 2.0 flow
Authorization request, token exchange, and refresh token — the OAuth 2.0 Authorization Code Flow with PKCE.
Overview
Enbox uses the standard OAuth 2.0 Authorization Code Flow with PKCE (RFC 6749 + RFC 7636). Every application on every device is a separate OAuth2 client with its own client_id.
The flow has three stages:
- Authorize — user logs in and approves the requested permissions (consent screen)
- Token exchange — client exchanges the authorization code for access + refresh tokens
- Refresh — client rotates the refresh token to get a new access token
Prelogin (E2E key derivation)
Before signing in, the client needs the user’s salt_user and kdf_params to derive the password hash locally. The server never sees the plaintext password.
HTTP
Request
GET https://auth.enbox.net/prelogin?email=user@example.com
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string |
✅ | The user’s email address |
Response
{
"salt_user": "base64-encoded-16-byte-salt",
"kdf_params": {
"alg": "argon2id",
"memory_kib": 65536,
"iterations": 3,
"parallelism": 4,
"version": "0x13"
},
"missing": []
}
| Field | Type | Description |
|---|---|---|
salt_user |
string |
Base64-encoded 16-byte Argon2id salt |
kdf_params |
object |
Argon2id parameters for key derivation |
missing |
string[] |
Key types the user doesn’t have yet (e.g. ["pgp"]) |
For non-existent emails, the response contains deterministic fake values (derived from
HKDF(server_secret, email)) — indistinguishable from real ones to prevent email enumeration.
The client then:
- Derives
master_key = Argon2id(password, salt_user, kdf_params)locally - Derives
master_pwd_hash = PBKDF2(master_key, password, iter=1, sha256)locally - Submits
master_pwd_hash(not the plaintext password) in the sign-in form - If
missingcontains key types, the client generates them silently in the background and includes them in the sign-in POST
Authorization request
Starts the OAuth 2.0 authorization process. The user is redirected to this URL, logs in, and authorizes the application on the consent screen.
If the user is not logged in, they are redirected to the sign-in page first (with 2FA verification), then back to the consent screen.
HTTP
Request
GET https://auth.enbox.net/authorize
?response_type=code
&client_id=myapp-client-id
&redirect_uri=https://myapp.com/callback
&scope=notes.note.view contacts.contact.read
&state=randomstring
&code_challenge=g91XPJpIt0PoaQXs2OEImlMdL_LGgH5HLrYxT-Ug5hk
&code_challenge_method=S256
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
response_type |
string |
✅ | Must be "code" |
client_id |
string |
✅ | The application’s Client ID |
redirect_uri |
string |
✅ | The callback URL — must exactly match the registered value |
scope |
string |
✅ | Space-separated permission keys (e.g. "notes.note.view contacts.contact.read") |
state |
string |
✅ | Random string for CSRF protection — returned as-is |
code_challenge |
string |
✅ | PKCE Code Challenge — BASE64URL(SHA256(code_verifier)), 43–128 chars |
code_challenge_method |
string |
✅ | Must be "S256" |
Required vs optional scopes
Each application declares two lists of scopes:
- Required — the app cannot function without these. Shown as locked checkboxes on the consent screen.
- Optional — the app works without them but offers additional functionality. Shown as toggleable checkboxes.
The user can uncheck any optional scope. The granted scope in the token response reflects only the permissions the user approved.
Response
On user approval:
302 Found
Location: https://myapp.com/callback?code=AUTH_CODE&state=randomstring
On user denial:
302 Found
Location: https://myapp.com/callback?error=access_denied&state=randomstring
On invalid parameters:
302 Found
Location: https://myapp.com/callback?error=invalid_scope&error_description=...&state=randomstring
Note:
client_idandredirect_urierrors return400directly (no redirect) to prevent open-redirect attacks.
Token exchange
Exchanges an authorization code for an access token and a refresh token. The authorization code is single-use and expires after 5 minutes.
HTTP
Request
POST https://auth.enbox.net/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id=myapp-client-id
&code=AUTH_CODE
&redirect_uri=https://myapp.com/callback
&code_verifier=randomstringxyz123456789
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
grant_type |
string |
✅ | Must be "authorization_code" |
client_id |
string |
✅ | The application’s Client ID |
code |
string |
✅ | Authorization code received from /authorize |
redirect_uri |
string |
✅ | Must exactly match the value in the authorization request |
code_verifier |
string |
✅ | Original random string used to generate code_challenge |
client_secret |
string |
⚠️ | Required for confidential clients only |
Response
{
"access_token": "dQw4w9WgXcQ...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "refresh-token-xyz",
"scope": "notes.note.view contacts.contact.read",
"key_material": {
"salt_user": "base64",
"kdf_params": { "alg": "argon2id", "memory_kib": 65536, "iterations": 3, "parallelism": 4, "version": "0x13" },
"wrapped_account_sym": "base64",
"wrapped_account_priv": "base64",
"account_pub": "base64",
"wrapped_pgp_priv": "base64",
"pgp_pub": "base64"
}
}
| Field | Type | Description |
|---|---|---|
access_token |
string |
Bearer token for API requests |
token_type |
string |
Always "Bearer" |
expires_in |
number |
Access token lifetime in seconds (900 = 15 min) |
refresh_token |
string |
Long-lived token for obtaining new access tokens |
scope |
string |
Space-separated granted permissions |
key_material |
object |
Wrapped encryption keys (first-party clients only) |
key_materialis only returned ongrant_type=authorization_code(first login on device) and only for first-party clients. Third-party clients receive an empty object — they work with encrypted blobs via the API and never see key material.
Error responses
{
"error": "invalid_grant",
"error_description": "Invalid or expired authorization code."
}
| Error | Cause |
|---|---|
invalid_request |
Missing required parameter |
invalid_client |
Unknown/inactive client, or bad client_secret |
invalid_grant |
Invalid/expired code, PKCE failure, redirect_uri mismatch |
unsupported_grant_type |
Grant type is not authorization_code or refresh_token |
Refresh token
Use a refresh token to obtain a new access token. The old refresh token is revoked and a new one is issued (rotation).
HTTP
Request
POST https://auth.enbox.net/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&client_id=myapp-client-id
&refresh_token=refresh-token-xyz
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
grant_type |
string |
✅ | Must be "refresh_token" |
client_id |
string |
✅ | The application’s Client ID |
refresh_token |
string |
✅ | The refresh token received earlier |
client_secret |
string |
⚠️ | Required for confidential clients only |
Response
{
"access_token": "new-access-token",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "new-refresh-token",
"scope": "notes.note.view contacts.contact.read"
}
Rotation: The old refresh token is revoked on every refresh. Using a revoked refresh token returns
invalid_grant. The old access token is deleted immediately. Nokey_materialis returned on refresh — the client already has the keys from the initial login.
Token TTL summary
| Token | Storage | Lifetime | Notes |
|---|---|---|---|
| Authorization code | Redis | 5 min | Single-use (atomic GETDEL) |
| Access token | Redis | 15 min | Resolved on every API request |
| Refresh token | PostgreSQL | 12 months | Rotated on every refresh |