@column-na/card-reveal
Show a Column card's full number, CVV, expiry and cardholder name on your web page. The card data never touches your code or your servers.
How it works
- The browser calls
client.prepareReveal(). Column's frame athttps://reveal.column.commakes the reveal's one-time key, and your page gets only its thumbprint,keyThumbprint. Send it to your server with the card ID. - Your server creates a single-use reveal token, valid for one minute, bound to that key, using your API key:
Check that the signed-in user owns the card before you do this. Then send only the tokencurl -X POST 'https://api.column.com/issuing/cards/<card_id>/reveal-tokens' -u :<YOUR API KEY> \ -H 'Content-Type: application/json' -d '{"reveal_key_thumbprint": "<keyThumbprint>"}' # {"id":"crtk_...","card_id":"card_...","expires_at":"...","reveal_key_thumbprint":"..."}idto the browser. - The browser calls
prepared.reveal(...)with the card ID and token. Each value renders inside its own isolated iframe served by Column, so your page's JavaScript can't read it. The frame fetches the card details straight from Column's API, encrypted end to end to the one-time key, which never leaves the frame. The token works only with that key, so a token that leaks from your logs or your page is useless to anyone else.
Install
npm install @column-na/card-reveal
Or load the hosted script, pinned to an exact version with Subresource Integrity. Every version's tag, including its hash, is in that version's release notes:
<script
src="https://reveal.column.com/<version>/card-reveal.js"
integrity="sha384-..."
crossorigin="anonymous"
></script>
<script>
const client = window.ColumnCardReveal.createCardRevealClient({ environment: 'live' });
</script>
The hosted script refuses to create a client (integrity_required) unless its <script> tag has an integrity attribute holding one or more sha256-, sha384- or sha512- hashes, the values browsers verify. The SDK reads the attribute the way browsers do: hashes are separated by ASCII whitespace, and every one must be well-formed. A placeholder, an algorithm no browser checks, or a hash a browser can't parse (one followed by a non-breaking space pasted from a document, say) is refused the same way, because a browser skips what it can't parse and then loads the script with no check at all. Load it with a classic <script src> tag as above: <script type="module"> isn't supported, because a module script can't see its own tag and so can't tell that it was verified. Each version's file is published once and never changes. Versions up to 0.1.3 are at https://js.column.com/card-reveal/<version>/card-reveal.js, and stay there.
Usage
import { createCardRevealClient, CardRevealError } from '@column-na/card-reveal';
// Create one client and reuse it.
const client = createCardRevealClient({ environment: 'live' }); // or 'sandbox'
// One prepared reveal per token: makes the one-time key, and gives you its thumbprint to mint with.
const prepared = await client.prepareReveal();
const revealToken = await myServer.createRevealToken(cardId, prepared.keyThumbprint);
const { ready, unmount } = prepared.reveal({
cardId,
revealToken,
fields: {
pan: '#card-number',
cvv2: '#cvv2',
cardholder_name: '#name',
expiration_month: '#exp-month',
expiration_year: '#exp-year',
},
style: { color: '#fff', 'font-family': 'ui-monospace, monospace', 'font-size': '15px' },
fieldStyles: { pan: { 'font-size': '19px' } },
format: { pan: 'spaced', expirationYear: 'YY' },
timeoutMs: 15000,
});
try {
await ready;
} catch (error) {
if (CardRevealError.is(error)) showMessage(error.code); // error.field and error.status when known
}
// Later, or when the component unmounts:
unmount();
In React, use the bindings below, or call reveal in an effect and return unmount as its cleanup. Each reveal needs a new token, because tokens are single use, and a new prepareReveal(), because each prepared key is used once.
React
@column-na/card-reveal/react does the prepare → mint → reveal → unmount sequence for you (React 18 or later):
import { CardRevealProvider, CardRevealSlot } from '@column-na/card-reveal/react';
<CardRevealProvider
client={client}
cardId={cardId}
mintToken={({ cardId, keyThumbprint }) => myServer.createRevealToken(cardId, keyThumbprint)}
>
<CardRevealSlot field="pan" style={{ width: 280, height: 30 }} />
<CardRevealSlot field="cvv2" style={{ width: 60, height: 30 }} />
</CardRevealProvider>;
mintToken({ cardId, keyThumbprint })asks your server for a token bound to the reveal's key and resolves with itsid. It's called once per reveal.<CardRevealSlot field="...">is an empty<div>that one field's frame fills, so give it a width and height. It takes anydivattribute.<CardRevealProvider>also takesstyle,fieldStyles,format,timeoutMsandenabled(defaulttrue;falseholds off, or hides a shown card).- Inside it,
useCardRevealState()returns{ status, error, retry }.statusis'idle','preparing','minting','revealing','ready'or'error'.erroris aCardRevealError(check withCardRevealError.is) or whatevermintTokenthrew.retry()starts over with a new key and token. - Changing the client, card, slots, styles,
formatortimeoutMsreveals again with a new token; unmounting the provider unmounts the reveal. A key prepared for a reveal that never happens (an unmount, or Strict Mode's double mount) is discarded. useCardReveal({ client, cardId, mintToken, fields })is the same as a hook, withfieldsmapping each field to a CSS selector, if you'd rather manage the containers yourself.
Key-bound tokens: prepareReveal()
client.prepareReveal() makes the next reveal's one-time P-256 key pair inside Column's frame, as a non-extractable WebCrypto key, before the token exists. It resolves with:
keyThumbprint: the key's RFC 7638 SHA-256 thumbprint, base64url without padding (43 characters). Pass it to your server, which mints the token with it asreveal_key_thumbprint. It's a hash of a public key, not a secret.reveal(options): reveals with that key, with the same options and result asclient.reveal(). It may be called once; a second call throwsinvalid_options. Invalid options throw before the key is used, so a corrected call can still use it.discard(): drops the key without revealing and removes its hidden frame, for when the reveal won't happen (the token couldn't be minted, or the page moved on). Afterwardsreveal()throwsinvalid_options.
Column answers a bound token only for a reveal that sends that key. Any other key gets a 401 (reveal_failed), and the token isn't used up. prepareReveal() rejects with script_load_failed, reveal_failed or timeout if the frame can't make the key. Until the reveal, the key waits in a hidden, zero-size frame at the end of <body>; the reveal removes it.
Every reveal token is minted with a reveal_key_thumbprint, so every reveal starts with prepareReveal(). client.reveal() takes the same options, but a token minted for another key, or a reveal made without the prepared key, gets a 401.
Options
| Option | Description |
|---|---|
cardId | The card's ID (card_...). |
revealToken | A reveal token ID (crtk_...) from your server. Single use, valid for 1 minute. |
fields | Maps any of pan, cvv2, cardholder_name, expiration_month, expiration_year and pin to a CSS selector for its container. Include only the fields you want; see Revealing the PIN. |
style | Styles for every field. |
fieldStyles | Per-field styles, applied on top of style. |
format.pan | 'spaced' (default: 4111 1111 1111 1111 for 16-digit numbers) or 'plain'. |
format.expirationYear | 'YYYY' (default) or 'YY'. |
timeoutMs | How long ready waits before rejecting with timeout. Default 15000, maximum 120000. |
copyable | true, or per field ({ pan: true }): the field becomes a copy button inside Column's frame. See Copy, hide and show. Web only. |
onCopy | Called with { field, copied } after each copy, where copied says whether the browser accepted it. Never the value. |
hidden | true reveals the fields masked (••••) until you call show(). Default false. |
autoHideMs | Unmounts the reveal this many milliseconds after ready resolves (1 to 3600000). |
onAutoHide | Called once when autoHideMs unmounts the reveal. |
Style objects accept CSS properties (kebab-case or camelCase), pseudo-class blocks such as '&:hover': { ... }, '@media (...)': { ... } and '@font-face': { ... }. The styles apply inside the iframe, and each container decides the iframe's size, so give your containers a width and height.
An '@font-face' rule's src must embed the font as a base64 data: URL (url(data:font/ttf;base64,...) format("truetype")), or name an installed font with local(...). A src that points at a URL is dropped and the iframe never fetches it: which fonts a frame loaded would tell that server which characters the card data contains. A style, including its embedded fonts, may be up to 256 KB as JSON.
An embedded font must be plain TrueType: an sfnt file (.ttf, signature 00 01 00 00 or true) made only of the cmap, glyf, head, hhea, hmtx, loca, maxp, name, post, OS/2 and gasp tables, whose every glyph is a simple outline (no composite glyphs) drawn within about 2 em of its origin (between -1 em and +2 em on each axis), with no advance width over 2 em, unhinted (no cvt , fpgm or prep table and no glyph instructions), at most 64 KB. WOFF and WOFF2, OpenType CFF (.otf), font collections, hinted fonts, and fonts with layout (GSUB, GPOS, GDEF), colour or variation tables don't pass. A font that doesn't pass is dropped silently, the same as an unsafe value, and the text renders in the next font of your font-family list, so list a fallback. Why: the page can time a reveal, and a font's size doesn't bound what painting it costs. A glyph may be drawn far outside its em box whatever the font-size, WOFF2 packs megabytes of outlines into kilobytes, a substitution feature expands one character into many glyphs, composite glyphs multiply points, and a hinting program is bytecode the browser's rasterizer runs for each glyph; each could make the time depend on which digits the value contains.
Preparing a font. A brand font as shipped usually carries kerning (GPOS), ligatures (GSUB) and hinting, and often composite glyphs, so export a copy for the reveal: plain TrueType, unhinted, no layout tables, only the characters you show. With fontTools:
pyftsubset brand.ttf --flavor= --no-hinting --layout-features='' --drop-tables+=GSUB,GPOS,GDEF,DSIG,kern --unicodes=U+0020-007E --output-file=brand-reveal.ttf
Then decompose any composite glyphs the subset keeps (fontTools' DecomposingRecordingPen replayed into a TTGlyphPen, as conformance/scripts/plain-fonts.py in the SDK repository does), check the file is at most 64 KB, and embed it as url(data:font/ttf;base64,...) format("truetype").
Styles are bounded, because the page can time a reveal and how long a value takes to paint must not depend on what it says. With a page-supplied font whose glyph for one digit is very expensive to paint, large text under blurred shadows took measurably longer when that digit was in the value, in every browser. So:
font-sizeis clamped to 96px (emitted asmin(<your value>, 96px), whatever the unit), and thefontshorthand,zoom,font-size-adjustandtext-size-adjustare refused. The font-size keywords (large,larger, …) pass through.text-shadowandbox-shadowkeep at most 2 shadows, each with a blur of at most 8px (given in px). A value with more shadows, a larger blur, or avar()orcalc()in it is dropped altogether.filter,backdrop-filter,transform(andtransform-*,translate,rotate,scale,perspective),-webkit-text-stroke,text-emphasis,mask,paint-orderandwill-changeare refused, as is-webkit-box-reflect. A vendor-prefixed spelling (-webkit-transform,-moz-transform,-webkit-filter, …) is treated as the property it aliases, so it is refused, clamped or bounded the same way.- Each embedded
@font-facefont may be at most 64 KB decoded and must be plain unhinted TrueType with simple glyphs inside their em box, as above;size-adjustis refused. Each glyph may have at most 600 points and 30 contours (a complex text glyph has under 400 and 30), and its outline may be at most 32 em long, measured along the axes (the sum of the horizontal and vertical distances between consecutive points around every contour): what a glyph costs to paint is how many scanlines its edges cross, a 64 KB font can hold a glyph of 30,000 points whose every edge spans the box, and even 600 such points are 1,800 em of edges. Every text glyph in a survey of macOS's TrueType fonts, the densest CJK glyphs of Songti and STHeiti included, is under 26 em; only swash ligatures, shade patterns and calligraphic Arabic ligatures exceed 32 em. - The frame never synthesizes bold or oblique and never computes skip-ink intersections (
font-synthesis: noneandtext-decoration-skip-ink: nonein its own stylesheet, which a style can't override), since both multiply the paint work per glyph. Declare a bold or italic face in@font-faceif you want one;font-weightandfont-stylethen pick it. An underline is drawn straight through descenders. background-clip: textis refused, as the longhand (prefixed or not) and inside thebackgroundshorthand: it is one more pass through the glyph outlines on every paint. Avar()inbackground-cliporbackgroundis refused too, since it could expand totext; write the value out.
A property outside these bounds is dropped silently, the way an unsafe value is; the reveal still succeeds.
ready, unmount, hide and show
readyresolves once every requested field has rendered.- If any field fails, or the timeout passes,
readyrejects and every field is removed. A reveal shows all of its fields or none of them. unmount()removes every field. Ifreadyis still pending, it rejects withunmounted. Areadythat already resolved stays resolved.hide()andshow()mask and unmask every field; see Copy, hide and show.- Invalid options throw
invalid_optionssynchronously fromreveal().
Copy, hide and show
const reveal = client.reveal({
cardId,
revealToken,
fields: { pan: '#pan', cvv2: '#cvv2' },
copyable: { pan: true },
onCopy: ({ field, copied }) => showToast(copied ? 'Card number copied' : "Couldn't copy"),
autoHideMs: 60_000,
onAutoHide: () => showRevealButton(),
});
hideButton.onclick = () => reveal.hide();
showButton.onclick = () => reveal.show();
- Copy. A copyable field is a button inside the frame (focusable,
role="button", title "Copy"). A click, Enter or Space writes the value to the clipboard from inside the frame, so it never passes through your page; you get onlyonCopy({ field, copied }). A card number is copied without spaces. The copy control has the classcopyable, so a style can target it:'&.copyable:hover','&:focus-visible': { outline: '2px solid #0a84ff' }. Show your own confirmation fromonCopy. - Browsers. Current Chrome, Edge, Firefox and Safari copy on click, Enter and Space. The SDK delegates
clipboard-writeto copyable frames only (allow="clipboard-write"), and falls back todocument.execCommand('copy')inside the frame where the Clipboard API is unavailable or refused. If neither works,copiedisfalseand nothing is written. hide()andshow()mask every field in place (each character but spaces shows as•, so the layout stays) and show it again, without a new token or request. A hidden field doesn't copy. Both do nothing afterunmount()or a failure.hidden: truestarts masked.autoHideMsremoves the fields that long afterready, asunmount()does: the values are gone, and showing them again needs a new token.readystays resolved, andonAutoHideis called once. Usehide()instead if you want the cardholder to show them again.
Errors
Every error is a CardRevealError with a stable code. Use CardRevealError.is(error) to check, since it also works across separate copies of the SDK.
code | Meaning |
|---|---|
invalid_options | Bad options (thrown synchronously), or no browser environment. |
integrity_required | The hosted script's <script> tag has no integrity attribute with a sha256-, sha384- or sha512- hash, or it was loaded as a module script. |
script_load_failed | Column's reveal frame couldn't load. The next reveal() retries. |
container_not_found | No element matches a field's selector. field is set. |
reveal_failed | The reveal request failed. status is set when known, and apiCode when Column's API returned an error code. 401 means the token is missing, invalid, already used, expired, for a different card or bound to a different reveal key. |
timeout | timeoutMs passed before every field rendered. |
unmounted | unmount() was called before the reveal finished. |
Error messages never contain the token or any card data, and the SDK never logs either.
Revealing the PIN
Add pin to fields to show a debit card's PIN. The SDK asks Column only for the fields you list, so the PIN is returned only when you include pin, and a PIN-only reveal doesn't return the card number or CVV. Your server creates reveal tokens the same way either way. The card's program must have PIN reveal enabled. Verify the cardholder before you reveal their PIN.
Reveal pin on its own rather than alongside the card number. If the PIN can't be revealed, the whole reveal fails, so asking for it with other fields would hide those too. When it fails, reveal_failed carries Column's error code in apiCode:
apiCode | Meaning |
|---|---|
pin_not_revealable | The PIN was set before PIN reveal was available. Set the PIN again to make it revealable. |
feature_not_enabled | PIN reveal isn't enabled for the card's program. |
invalid_field_value | The card has no PIN, or isn't a debit card. |
These failures happen before the reveal token is used, so the same token still works for a reveal without pin.
Sandbox and live
| Live | Sandbox | |
|---|---|---|
| Data | Real cards | Simulated cards |
| Rendering | One iframe per field | One iframe per field |
| Request | Live-Mode: Yes | Live-Mode: No |
Both render in Column's reveal frame, which fetches the card details from GET https://api.column.com/issuing/cards/{id}/reveal, encrypted end to end, so sandbox looks and behaves like live. In sandbox, sandboxBaseUrl can point the frame at a local mock: it must be https://api.column.com (the default) or plain http://localhost or http://127.0.0.1 with any port. Anything else, including https on loopback, throws invalid_options. renderer: 'column' is no longer needed and changes nothing.
Content Security Policy
Every reveal renders in Column's frame at https://reveal.column.com, and your page fetches nothing itself.
script-src 'self' https://reveal.column.com;
frame-src https://reveal.column.com;
- Drop
https://reveal.column.comfromscript-srcif you install from npm. - Your page needs no
connect-srcfor the SDK, in sandbox or live, and no'unsafe-inline'.
Versioning
This package follows semantic versioning, and the hosted script and React Native SDK release with the same version. Pin an exact version. Every request sends X-Column-SDK-Version: card-reveal-js/<version> so Column can tell which version you run.
License
Proprietary. Use is subject to the Column Card Reveal SDK License in LICENSE and requires an agreement with Column.