Applications
OpenID Connect sign-in for your own site Β· general API guidance lives at /help/apiApplication or API key?
This is the decision to get right first. The two credentials look interchangeable and are not: an API key is you, an Application is somebody else.
| API key | Application (OAuth) | |
|---|---|---|
| Acts as | The account that created it β you. | Whichever user signed in and consented, one at a time. |
| Can write | No. Keys are read-only by design; the gateway refuses every mutation on a key-authenticated request. | Yes β anything that user could do themselves, within the scopes they approved. |
| Safe in a public page | Yes. That is what it is for. | The client id yes; the client secret never. |
| Rate budget | The key's own. | The signed-in user's β see below. |
| Reach for it when | You are displaying civic data β a widget, a dashboard, a script. | You want people to sign in to your site with their CivicGate account. |
If nobody has to log in to your thing, you want an API key and this page is the wrong one.
Registering an application
Register at Settings β Applications. You give it a name, an optional description, and at least one redirect URI. The server mints the client id from your name plus a random suffix β you do not choose it, because an id nobody can squat is worth more than a pretty one.
| Choice | What it means |
|---|---|
| Confidential (default) | Your server keeps a secret. Use this whenever you have a backend. |
| Public (tick the box) | Single-page app or mobile. No secret is issued β a browser or phone cannot keep one. PKCE takes its place. |
The secret exists in exactly one response
The column holds a bcrypt hash plus the first 12 characters for display. The
full secret (cgs_ + 32 random bytes, base64url) is returned once β at creation, or
at rotation β and there is no endpoint anywhere that can read it back. Lose it and your only
move is Rotate secret, which invalidates the old one immediately:
rotation is revocation, so sign-in through that application breaks until you have deployed the
new value.
Limits on registration
| Applications per account | 10 |
| Redirect URIs per application | 10 (512 characters each) |
| Registrations per hour | 10, per account |
| Name / description length | 100 / 500 characters |
The same endpoints back a scripted workflow, authorised with your normal session bearer token:
GET https://auth.civicgate.org/api/v1/oidc/clients list your own
POST https://auth.civicgate.org/api/v1/oidc/clients register (returns the secret ONCE)
GET https://auth.civicgate.org/api/v1/oidc/clients/{clientId}
PATCH https://auth.civicgate.org/api/v1/oidc/clients/{clientId} name, description, logo, URIs, scopes, status
DELETE https://auth.civicgate.org/api/v1/oidc/clients/{clientId}
POST https://auth.civicgate.org/api/v1/oidc/clients/{clientId}/rotate-secret
Every one of those filters on your user id inside the query, and a client that is not yours
answers 404 rather than 403 β a distinguishable "forbidden" would
confirm which client ids exist.
Endpoints
CivicGate publishes an OIDC Discovery document, so most libraries need only the issuer
(https://auth.civicgate.org) and will configure themselves. Hand-wiring six URLs is where integrations
usually get one wrong.
| Purpose | Endpoint |
|---|---|
| Discovery | GET https://auth.civicgate.org/.well-known/openid-configuration |
| JWKS (public keys) | GET https://auth.civicgate.org/.well-known/jwks.json |
| Authorization | GET https://auth.civicgate.org/api/v1/oauth/authorize |
| Token | POST https://auth.civicgate.org/api/v1/oauth/token |
| UserInfo | GET or POST https://auth.civicgate.org/api/v1/oauth/userinfo |
| RP-initiated logout | GET https://auth.civicgate.org/api/v1/oauth/logout |
| Revocation | https://auth.civicgate.org/api/v1/oauth/revoke β
advertised in discovery but not implemented; it answers 404.
See known gaps.
|
Supported: response_type=code only, response_mode=query,
RS256 id tokens, and code_challenge_method=S256 only β
plain PKCE is refused outright rather than accepted as a flow that merely looks
protected. Client authentication may be client_secret_basic,
client_secret_post or none (public clients).
The authorization-code + PKCE flow
1 Β· Send the person to the authorization endpoint
A top-level navigation, not a fetch. Generate a random
code_verifier, keep it server-side or in session storage, and send its SHA-256
digest, base64url-encoded, as the challenge.
https://auth.civicgate.org/api/v1/oauth/authorize
?response_type=code
&client_id=my-community-site-9f2a1c4b7e08
&redirect_uri=https://example.com/auth/callback
&scope=openid%20profile%20email
&state=<random; compare it on the way back>
&nonce=<random; compare it to the id_token's nonce claim>
&code_challenge=<BASE64URL(SHA256(code_verifier))>
&code_challenge_method=S256
openid is required β without it the request fails with
invalid_scope. PKCE is required for every self-registered application, public or
confidential: registration forces require_pkce on and it is not editable.
A navigation cannot carry an Authorization header
Only fetch can. That is why this one endpoint accepts the identity server's
session cookie as well as a bearer token β without it, a reader who was
already signed in arrived as an anonymous stranger and got bounced to the login page, which
handed back a token the next navigation still could not send. So: do not try to
attach a token to the authorize URL, and do not fetch it with XHR to read the
Location β let the browser go there.
2 Β· They sign in and approve
Not signed in, they are sent to https://www.civicgate.org/login with the full
authorize URL as next, and return here afterwards. Then CivicGate shows a consent
screen naming your application and listing, in plain language, what it is asking for. Approval
is carried by a server-minted nonce valid for 10 minutes, so an authorize URL
with consent=granted pasted on cannot approve anything β never bookmark or
replay a consent URL; build a fresh authorize request each time.
3 Β· CivicGate redirects back with a code
302 β https://example.com/auth/callback?code=β¦&state=β¦
Compare state to what you sent. The code lives 2 minutes and is
single-use. If something went wrong after the redirect URI was validated, you get
?error= + error_description= instead β invalid_scope,
unsupported_response_type, unauthorized_client,
invalid_request. If it went wrong before that (unknown client, or a
redirect URI that is not registered) the error renders on CivicGate and no redirect happens at
all, because redirecting to an unvalidated address is the exact attack this endpoint exists to
refuse.
4 Β· Exchange the code for tokens
curl -X POST https://auth.civicgate.org/api/v1/oauth/token \
-u 'my-community-site-9f2a1c4b7e08:cgs_β¦' \
-d grant_type=authorization_code \
-d code=β¦ \
-d redirect_uri=https://example.com/auth/callback \
-d code_verifier=β¦
{
"access_token": "β¦",
"id_token": "β¦",
"token_type": "Bearer",
"expires_in": 900,
"scope": "openid profile email"
}
Read the scope you got back β the granted set can be narrower
than the set you asked for, and code that assumes otherwise misbehaves the first time a scope
is refused. A public client sends no secret; its code_verifier is
what proves it is the same party that started the flow.
redirect_uri must match the one the code was issued against. Failures are
deliberately indistinguishable β unknown, expired and already-redeemed codes all answer
invalid_grant, because saying which would help only somebody who had stolen one.
One exception worth knowing: access_denied means the code was fine and the
person is not authorised for that application, which sends you to an administrator
rather than to your PKCE implementation.
5 Β· Verify the id_token
RS256, verified against JWKS β pick the key by
the token's kid header, which is what makes a key rotation a non-event. Check
iss, aud (your client id), expiry, and that nonce equals
what you sent. Claims are filtered by scope: with only openid you
get a subject and nothing else β not the name, not the email.
6 Β· Use the access token
curl https://auth.civicgate.org/api/v1/oauth/userinfo -H 'Authorization: Bearer β¦'
The same token authenticates that user against the CivicGate GraphQL API, which is what makes
an Application able to write on their behalf. Two things to know before you build on that:
- Mutations go to
https://www.civicgate.org/graphql. The public
endpoint api.civicgate.org/graphql is read-only for every credential and
refuses mutations regardless of who you are.
- What you can call with it is the generated schema reference:
/api/reference.
- A
client_credentials token is not a CivicGate API credential.
The gateway recognises service tokens and rejects them, deliberately β there is no
app-scoped authorisation model yet, so "not this credential" is said out loud rather than
returned as an empty result set that looks like an empty account. Self-registered
applications cannot hold that grant in any case.
Redirect URI rules
This is the control the whole flow rests on. The authorize endpoint delivers a user's
authorization code to whatever address your client names, and the only thing stopping an
attacker naming their own is that it must already appear β character for
character β in your registered list. Everything below exists so that exact match
cannot be written loosely enough to stop meaning anything.
Rule The server's own message No wildcards "Wildcards are not allowed in redirect URIs β register each exact address separately" No fragment "Redirect URIs must not contain a fragment (the part after #) β the browser never sends it, so it cannot be matched" https only, except loopback "Redirect URIs must use https β plain http is allowed only for local development (http://localhost or http://127.0.0.1, with any port)" No custom schemes "Redirect URIs must use https β custom schemes are not accepted" No userinfo "Redirect URIs must not contain a username or password" No control characters "Redirect URIs must not contain tabs, line breaks or other control characters" Absolute "Redirect URIs must be absolute, starting with https:// (or http:// for localhost)" At least one "At least one redirect URI is required" β a client with none cannot complete a single login
The loopback exception is checked on the parsed host, not on a string prefix:
http://localhost.attacker.net/cb begins with http://localhost and is
a completely different, attacker-controlled host. Only localhost,
127.0.0.1 and ::1 qualify, on any port.
Stored values are trimmed of spaces and de-duplicated, and otherwise kept byte for
byte. Nothing is lowercased and no trailing slash is added or removed, because the
stored string is what the authorize endpoint compares against β "helpfully" normalising it
would store something your client never sends, and every login would fail with a mismatch you
did not cause. Register https://example.com/cb and
https://example.com/cb/ separately if you use both.
The rules are enforced identically on edit as on registration. A rule that only
applies at creation is not a rule.
Scopes
Space-separated in scope. Unknown values are ignored, not rejected.
Scope Grants Self-service? openidRequired. The subject (sub) and the id token itself. Yes profilename, given_name, family_name, preferred_username, picture, cg_roles.Yes emailemail and email_verified.Yes offline_accessA refresh token β standing, long-lived access. Administrator civic:locationCity, state and country. Never the street address or postal code, at any tier. Administrator civic:interestsThe civic topics the member follows. Administrator civic:positionsPositions they have declared on issues. Sensitive. Administrator civic:activityRepresentatives contacted and what was urged. Sensitive. Administrator
A self-registered application may request openid, profile and
email, and asking for anything else is refused rather than
silently trimmed β a silent drop would leave you with a client that authorises fine and then
returns a token missing the claim you built on, with nothing anywhere explaining why. The
message names the alternative:
A self-registered application may request only these scopes: openid, profile, email.
"civic:positions" is granted by a CivicGate administrator β contact us if your
application needs it.
The civic:* scopes read data a member has not agreed to hand to a stranger, so
they are a human decision rather than a form field. The two marked sensitive
carry political-belief data about a private individual and re-prompt on every
authorization, even for a client the person approved before β silence must never be
read as consent for that.
Token lifetimes and refresh
Credential Lives Authorization code 2 minutes Single use. It is a bearer credential in a URL, the most-logged place a secret can be. ID token 10 minutes Proof of a login event, not a session. Do not treat it as one. Access token 15 minutes expires_in: 900. Refresh token 7 days Only issued under the conditions below. Consent approval 10 minutes An expired consent screen re-prompts rather than erroring.
You probably will not get a refresh token
A refresh token is returned only when both hold: the granted scopes include
offline_access, and the client is registered for the refresh_token
grant. Self-registered clients are registered for the grant β and
cannot request offline_access, so in practice they never receive
one. That is deliberate: standing long-lived access to somebody's account is not something a
registration form should be able to award itself.
So the normal pattern for a self-service application is: hold your own session for the person,
and when you need CivicGate again, run the authorization flow again. Because they already
consented, and their identity-server session is still live, the round trip is silent β no
second consent screen unless a sensitive scope is involved.
If an administrator has granted your application offline_access:
curl -X POST https://auth.civicgate.org/api/v1/oauth/token \
-u 'CLIENT_ID:CLIENT_SECRET' \
-d grant_type=refresh_token \
-d refresh_token=β¦
A refresh token is bound to the client it was issued to and cannot be redeemed by another, so
a token you obtained by any other route will be refused here.
Signing out
https://auth.civicgate.org/api/v1/oauth/logout?client_id=β¦&post_logout_redirect_uri=https://example.com/
The redirect target must be registered as a post-logout URI on your client β
same reason as the authorize endpoint, otherwise this is an open redirect wearing a logout
costume. Unregistered (or omitted), it answers a plain JSON acknowledgement instead of
redirecting.
Rate limits β whose budget you are spending
An Application spends the budget of the user who authorised it, not its own.
This is not obvious and it changes how you plan capacity.
- Ten users means ten separate budgets. Your application has no aggregate cap of its own.
- One user hammering through your app exhausts only their own budget. Their neighbours are unaffected.
- Every user's limit is set by their account tier, not yours. A free-tier user is metered at the free rate even if you are not.
- Reads and writes share one budget. There is no separate write allowance.
Practically: batch per-user work, cache what you can, and do not fan a single background job
across many users' tokens expecting a pooled ceiling β you will hit each of them in turn.
The tier numbers, the RATE_LIMITED error shape and the
retryable flag are all on the API guide, which is the one place they are stated:
/help/api β Rate limits.
Troubleshooting
A CORS error that is not a CORS error
The browser says "No 'Access-Control-Allow-Origin' header is present on the requested
resource", and the auth service logs status=200 for that exact request.
Both are telling the truth. Three Set-Cookie headers, one carrying a large
access JWT, can exceed nginx's default 4KB proxy_buffer_size; nginx then
discards the upstream 200 and returns its own 502, which carries none of
the CORS headers the upstream had set. It is size-dependent β a user with
fewer roles has a smaller token and slips under the limit β so it presents as intermittent
and as affecting only some accounts.
Check your proxy's error log for upstream sent too big header before
touching any CORS configuration. The fix is buffer size, not headers:
proxy_buffer_size 16k; proxy_buffers 8 16k; proxy_busy_buffers_size 32k;
Symptom Usually redirect_uri is not registered for this client, rendered on CivicGate An exact-match miss β most often a trailing slash, a different port, or http against an https registration. Compare the two strings byte for byte. Sent to /login forever You fetched the authorize endpoint instead of navigating to it. See step 1. invalid_grant at the token endpoint The code expired (2 minutes), was already redeemed, or redirect_uri does not match the one it was issued against. It is also what a failed PKCE check returns. access_denied at the token endpoint Not your code and not your PKCE β that person is not authorised for the application. Only an administrator can change it. Token has no email or name You asked for openid alone. Claims are filtered by scope. Consent screen every single time Expected for civic:positions and civic:activity. Sensitive scopes always re-prompt. Mutations rejected with a read-only message You are calling api.civicgate.org. Writes go to www.civicgate.org/graphql.
Known gaps
Stated rather than left to be discovered, because both change what you can promise your users.
- Token revocation is advertised and not implemented. The discovery document
names
https://auth.civicgate.org/api/v1/oauth/revoke; that URL answers 404. Do not
build a sign-out that depends on it. Tokens expire on the schedule above,
and an application can be stopped outright by disabling or deleting it, or by rotating its
secret.
- There is no self-service consent withdrawal yet. The consent screen says a
person can revoke access in their account settings; that surface does not exist at the
moment. Until it does, deleting your application is what clears its consents β so if a user
asks you to disconnect them, do it on your side and say so honestly.
Related
- Settings β Applications β register and manage yours.
- API guide β credentials, rate limits, error handling.
- API keys β the read-only credential, for when nobody signs in.
- Schema reference β every query, mutation and type you can call as the signed-in user.
- Public API β the endpoint and what the data covers.
- Discovery document β point your OIDC library at it.